@gperezmz/bb-plugin-thread-glance 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/THIRD_PARTY_NOTICES.md +230 -0
- package/app.tsx +12 -0
- package/components/ui/alert-dialog.tsx +142 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +83 -0
- package/components/ui/coarse-pointer-sizing.ts +66 -0
- package/components/ui/context-menu.tsx +355 -0
- package/components/ui/dialog.tsx +416 -0
- package/components/ui/dropdown-menu.tsx +640 -0
- package/components/ui/hooks/use-compact-viewport.tsx +37 -0
- package/components/ui/hooks/use-media-query.ts +70 -0
- package/components/ui/hooks/use-pointer-coarse.ts +7 -0
- package/components/ui/hover-card.tsx +36 -0
- package/components/ui/icon-extended.tsx +333 -0
- package/components/ui/icon-registry.ts +189 -0
- package/components/ui/icon.tsx +444 -0
- package/components/ui/input.tsx +30 -0
- package/components/ui/menu-item-hover.tsx +93 -0
- package/components/ui/motion.ts +4 -0
- package/components/ui/overlay-trigger.ts +84 -0
- package/components/ui/popover.tsx +223 -0
- package/components/ui/responsive-overlay.tsx +823 -0
- package/components/ui/tooltip.tsx +63 -0
- package/dist/app.css +2 -0
- package/dist/app.js +66 -0
- package/dist/app.meta.json +11 -0
- package/dist/server.js +71 -0
- package/dist/server.js.map +6 -0
- package/dist/server.meta.json +11 -0
- package/features/thread-list/components/Dialogs.tsx +299 -0
- package/features/thread-list/components/FoldRows.tsx +147 -0
- package/features/thread-list/components/GroupSection.tsx +370 -0
- package/features/thread-list/components/ProviderBadge.tsx +57 -0
- package/features/thread-list/components/PullRequestBadge.tsx +50 -0
- package/features/thread-list/components/Rails.tsx +28 -0
- package/features/thread-list/components/RenameEditor.tsx +118 -0
- package/features/thread-list/components/RowMenu.tsx +168 -0
- package/features/thread-list/components/SplitMiniMap.tsx +55 -0
- package/features/thread-list/components/ThreadDetails.tsx +138 -0
- package/features/thread-list/components/ThreadList.tsx +855 -0
- package/features/thread-list/components/ThreadRowView.tsx +631 -0
- package/features/thread-list/components/Toolbar.tsx +296 -0
- package/features/thread-list/components/controller.ts +64 -0
- package/features/thread-list/components/glyphs.tsx +129 -0
- package/features/thread-list/components/row-card.ts +127 -0
- package/features/thread-list/data/storage.ts +18 -0
- package/features/thread-list/data/useAutoExpand.ts +53 -0
- package/features/thread-list/data/useClientPreferences.ts +20 -0
- package/features/thread-list/data/useNotes.ts +41 -0
- package/features/thread-list/data/useNow.ts +19 -0
- package/features/thread-list/data/usePreferences.ts +126 -0
- package/features/thread-list/data/useScheduled.ts +43 -0
- package/features/thread-list/data/useStamps.ts +68 -0
- package/features/thread-list/icons.ts +44 -0
- package/features/thread-list/index.ts +2 -0
- package/features/thread-list/model/attention.ts +89 -0
- package/features/thread-list/model/counters.ts +67 -0
- package/features/thread-list/model/details.ts +79 -0
- package/features/thread-list/model/drag.ts +104 -0
- package/features/thread-list/model/expansion.ts +71 -0
- package/features/thread-list/model/families.ts +297 -0
- package/features/thread-list/model/groups.ts +263 -0
- package/features/thread-list/model/labels.ts +50 -0
- package/features/thread-list/model/layout.ts +61 -0
- package/features/thread-list/model/menu.ts +78 -0
- package/features/thread-list/model/move.ts +46 -0
- package/features/thread-list/model/notes.ts +52 -0
- package/features/thread-list/model/provider-mark.ts +59 -0
- package/features/thread-list/model/share.ts +101 -0
- package/features/thread-list/model/sort.ts +78 -0
- package/features/thread-list/model/state.ts +330 -0
- package/features/thread-list/model/time.ts +107 -0
- package/features/thread-list/model/toggles.ts +99 -0
- package/features/thread-list/model/view.ts +669 -0
- package/features/thread-list/model/windowing.ts +27 -0
- package/features/thread-list/testing/fixtures.ts +162 -0
- package/hooks/useBrowserDimmingModal.ts +5 -0
- package/lib/portal-scope.ts +15 -0
- package/lib/utils.ts +17 -0
- package/package.json +96 -0
- package/server/cli.ts +107 -0
- package/server/failures.ts +136 -0
- package/server/import.ts +144 -0
- package/server/notes.ts +236 -0
- package/server/preference-store.ts +96 -0
- package/server/scheduled.ts +129 -0
- package/server/serial.ts +12 -0
- package/server/stamps.ts +142 -0
- package/server/startup.ts +121 -0
- package/server.ts +126 -0
- package/shared/contract.ts +73 -0
- package/shared/preferences.ts +241 -0
- package/shared/signals.ts +71 -0
- package/skills/thread-glance/SKILL.md +69 -0
- package/tsconfig.json +36 -0
- package/types/global-jsx.d.ts +10 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Test fixtures: sidebar threads, projects and a ready-made view builder.
|
|
2
|
+
import type {
|
|
3
|
+
PluginSidebarProject,
|
|
4
|
+
PluginSidebarSection,
|
|
5
|
+
PluginSidebarThread,
|
|
6
|
+
} from "@get-bb/plugin-sdk/app";
|
|
7
|
+
import { defaultPreferences, type Preferences } from "@/shared/preferences";
|
|
8
|
+
import { buildForest, type Forest } from "../model/families";
|
|
9
|
+
import { buildListView, type ListFilter, type ListView } from "../model/view";
|
|
10
|
+
import type { Targets } from "../model/expansion";
|
|
11
|
+
|
|
12
|
+
export const T0 = 1_780_000_000_000;
|
|
13
|
+
|
|
14
|
+
type ThreadOverrides = Omit<Partial<PluginSidebarThread>, "activity" | "environment"> & {
|
|
15
|
+
id: string;
|
|
16
|
+
activity?: Partial<PluginSidebarThread["activity"]>;
|
|
17
|
+
environment?: Partial<NonNullable<PluginSidebarThread["environment"]>> | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A read, idle root thread in `proj_a`, created at T0. */
|
|
21
|
+
export function makeThread(overrides: ThreadOverrides): PluginSidebarThread {
|
|
22
|
+
const { activity, environment, ...rest } = overrides;
|
|
23
|
+
const title = rest.title ?? `Thread ${overrides.id}`;
|
|
24
|
+
return {
|
|
25
|
+
projectId: "proj_a",
|
|
26
|
+
title,
|
|
27
|
+
titleFallback: null,
|
|
28
|
+
displayTitle: rest.displayTitle ?? title,
|
|
29
|
+
parentThreadId: null,
|
|
30
|
+
lifecycleOwnerThreadId: null,
|
|
31
|
+
sourceThreadId: null,
|
|
32
|
+
sectionId: null,
|
|
33
|
+
originKind: null,
|
|
34
|
+
originPluginId: null,
|
|
35
|
+
providerId: "claude-code",
|
|
36
|
+
status: "idle",
|
|
37
|
+
runtimeStatus: "idle",
|
|
38
|
+
queuedWork: "none",
|
|
39
|
+
hasPendingInteraction: false,
|
|
40
|
+
indicator: "none",
|
|
41
|
+
indicatorLabel: null,
|
|
42
|
+
isUnread: false,
|
|
43
|
+
isPinned: false,
|
|
44
|
+
pinnedAt: null,
|
|
45
|
+
pinSortKey: null,
|
|
46
|
+
isArchived: false,
|
|
47
|
+
archivedAt: null,
|
|
48
|
+
href: `/projects/${rest.projectId ?? "proj_a"}/threads/${overrides.id}`,
|
|
49
|
+
isHidden: false,
|
|
50
|
+
host: { id: "host_1", name: "Laptop" },
|
|
51
|
+
createdAt: T0,
|
|
52
|
+
updatedAt: T0,
|
|
53
|
+
lastReadAt: T0,
|
|
54
|
+
latestAttentionAt: T0,
|
|
55
|
+
...rest,
|
|
56
|
+
activity: {
|
|
57
|
+
workflows: 0,
|
|
58
|
+
backgroundAgents: 0,
|
|
59
|
+
backgroundCommands: 0,
|
|
60
|
+
planMode: 0,
|
|
61
|
+
goals: 0,
|
|
62
|
+
...activity,
|
|
63
|
+
},
|
|
64
|
+
environment:
|
|
65
|
+
environment === null
|
|
66
|
+
? null
|
|
67
|
+
: {
|
|
68
|
+
id: "env_main",
|
|
69
|
+
name: null,
|
|
70
|
+
branchName: "main",
|
|
71
|
+
path: "/work/repo",
|
|
72
|
+
isWorktree: false,
|
|
73
|
+
providerId: null,
|
|
74
|
+
workspaceDisplayKind: null,
|
|
75
|
+
...environment,
|
|
76
|
+
},
|
|
77
|
+
} as PluginSidebarThread;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const working = { status: "active", runtimeStatus: "active" } as const;
|
|
81
|
+
export const failedUnread = { status: "error", latestAttentionAt: T0 + 10, lastReadAt: T0 } as const;
|
|
82
|
+
export const finishedUnread = { status: "idle", latestAttentionAt: T0 + 10, lastReadAt: T0 } as const;
|
|
83
|
+
|
|
84
|
+
export function makeProject(
|
|
85
|
+
id: string,
|
|
86
|
+
name = id,
|
|
87
|
+
overrides: Partial<PluginSidebarProject> = {},
|
|
88
|
+
): PluginSidebarProject {
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
name,
|
|
92
|
+
isPersonal: false,
|
|
93
|
+
href: `/projects/${id}`,
|
|
94
|
+
settingsHref: `/projects/${id}/settings`,
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const PROJECTS: PluginSidebarProject[] = [
|
|
100
|
+
makeProject("proj_personal", "Personal", { isPersonal: true }),
|
|
101
|
+
makeProject("proj_a", "Alpha"),
|
|
102
|
+
makeProject("proj_b", "Beta"),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
export interface Scenario {
|
|
106
|
+
threads: PluginSidebarThread[];
|
|
107
|
+
projects?: PluginSidebarProject[];
|
|
108
|
+
sections?: PluginSidebarSection[];
|
|
109
|
+
prefs?: Partial<Preferences>;
|
|
110
|
+
filter?: ListFilter;
|
|
111
|
+
activeThreadId?: string | null;
|
|
112
|
+
targets?: Targets;
|
|
113
|
+
finishedAt?: Record<string, number>;
|
|
114
|
+
seenAt?: Record<string, number>;
|
|
115
|
+
scheduled?: Record<string, number>;
|
|
116
|
+
draftIds?: string[];
|
|
117
|
+
notes?: Record<string, import("@/shared/contract").ThreadNotes>;
|
|
118
|
+
now?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function forestOf(scenario: Scenario): Forest {
|
|
122
|
+
return buildForest({
|
|
123
|
+
threads: scenario.threads,
|
|
124
|
+
activeThreadId: scenario.activeThreadId ?? null,
|
|
125
|
+
finishedAt: scenario.finishedAt ?? {},
|
|
126
|
+
seenAt: scenario.seenAt ?? {},
|
|
127
|
+
scheduled: scenario.scheduled ?? {},
|
|
128
|
+
draftIds: new Set(scenario.draftIds ?? []),
|
|
129
|
+
notes: scenario.notes ?? {},
|
|
130
|
+
childAttention: scenario.prefs?.childAttention,
|
|
131
|
+
now: scenario.now ?? T0 + 60_000,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function viewOf(scenario: Scenario): ListView {
|
|
136
|
+
const forest = forestOf(scenario);
|
|
137
|
+
return buildListView({
|
|
138
|
+
forest,
|
|
139
|
+
threads: scenario.threads,
|
|
140
|
+
projects: scenario.projects ?? PROJECTS,
|
|
141
|
+
sections: scenario.sections ?? [],
|
|
142
|
+
prefs: { ...defaultPreferences(), ...scenario.prefs },
|
|
143
|
+
filter: scenario.filter ?? "all",
|
|
144
|
+
activeThreadId: scenario.activeThreadId ?? null,
|
|
145
|
+
targets: scenario.targets ?? new Map(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Thread ids in the group's rows, in order; older rows as `older:N`. */
|
|
150
|
+
export function rowIds(view: ListView, groupId: string): string[] {
|
|
151
|
+
const group = [...view.groups, ...view.more].find((candidate) => candidate.descriptor.id === groupId);
|
|
152
|
+
if (group === undefined) throw new Error(`no group ${groupId}`);
|
|
153
|
+
return group.rows.map((row) =>
|
|
154
|
+
row.type === "thread"
|
|
155
|
+
? row.info.thread.id
|
|
156
|
+
: row.type === "older"
|
|
157
|
+
? `older:${row.count}`
|
|
158
|
+
: row.type === "empty"
|
|
159
|
+
? `empty:${row.scopeId}`
|
|
160
|
+
: `env:${row.environmentId}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare const __BB_PLUGIN_ID__: string | undefined;
|
|
2
|
+
|
|
3
|
+
export function usePortalScopeProps(): {
|
|
4
|
+
"data-bb-portaled-overlay": "";
|
|
5
|
+
"data-bb-plugin-root"?: "";
|
|
6
|
+
"data-bb-plugin"?: string;
|
|
7
|
+
} {
|
|
8
|
+
const pluginId =
|
|
9
|
+
typeof __BB_PLUGIN_ID__ === "string" ? __BB_PLUGIN_ID__ : undefined;
|
|
10
|
+
return {
|
|
11
|
+
"data-bb-portaled-overlay": "",
|
|
12
|
+
"data-bb-plugin-root": "",
|
|
13
|
+
...(pluginId !== undefined ? { "data-bb-plugin": pluginId } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
package/lib/utils.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatHomePathForDisplay(pathValue: string): string {
|
|
9
|
+
const homePrefix =
|
|
10
|
+
pathValue.match(/^\/Users\/[^/]+(?=\/|$)/)?.[0] ??
|
|
11
|
+
pathValue.match(/^\/home\/[^/]+(?=\/|$)/)?.[0] ??
|
|
12
|
+
pathValue.match(/^\/root(?=\/|$)/)?.[0] ??
|
|
13
|
+
pathValue.match(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+(?=[\\/]|$)/i)?.[0];
|
|
14
|
+
return homePrefix === undefined
|
|
15
|
+
? pathValue
|
|
16
|
+
: `~${pathValue.slice(homePrefix.length)}`;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gperezmz/bb-plugin-thread-glance",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"bb": ">=0.43",
|
|
7
|
+
"bbPluginSdk": ">=0.5.9"
|
|
8
|
+
},
|
|
9
|
+
"bb": {
|
|
10
|
+
"name": "Thread Glance",
|
|
11
|
+
"description": "Sidebar thread list that shows each thread's state, harness and child threads at a glance.",
|
|
12
|
+
"branding": {
|
|
13
|
+
"icon": "ListView"
|
|
14
|
+
},
|
|
15
|
+
"server": "./server.ts",
|
|
16
|
+
"app": "./app.tsx",
|
|
17
|
+
"skills": [
|
|
18
|
+
"skills"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@dnd-kit/core": "^6.3.1",
|
|
23
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
24
|
+
"@hugeicons/core-free-icons": "^4.3.4",
|
|
25
|
+
"@hugeicons/react": "^1.1.6",
|
|
26
|
+
"@radix-ui/react-slot": "^1.3.0",
|
|
27
|
+
"zod": "^4.3.6"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@get-bb/plugin-sdk": "0.5.9",
|
|
31
|
+
"@pierre/diffs": "^1.2.9",
|
|
32
|
+
"@radix-ui/react-alert-dialog": "^1.1.19",
|
|
33
|
+
"@radix-ui/react-context-menu": "^2.3.3",
|
|
34
|
+
"@radix-ui/react-dialog": "^1.1.19",
|
|
35
|
+
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
|
36
|
+
"@radix-ui/react-hover-card": "^1.1.19",
|
|
37
|
+
"@radix-ui/react-menubar": "^1.1.20",
|
|
38
|
+
"@radix-ui/react-navigation-menu": "^1.2.18",
|
|
39
|
+
"@radix-ui/react-popover": "^1.1.19",
|
|
40
|
+
"@radix-ui/react-select": "^2.3.3",
|
|
41
|
+
"@radix-ui/react-tooltip": "^1.2.12",
|
|
42
|
+
"@testing-library/dom": "^10.4.2",
|
|
43
|
+
"@testing-library/react": "^16.3.3",
|
|
44
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
45
|
+
"@types/node": "^22.0.0",
|
|
46
|
+
"@types/react": "^19.0.0",
|
|
47
|
+
"@types/react-dom": "^19.0.0",
|
|
48
|
+
"better-sqlite3": "^12.0.0",
|
|
49
|
+
"class-variance-authority": "^0.7.1",
|
|
50
|
+
"clsx": "^2.1.1",
|
|
51
|
+
"cron-parser": "^5.5.0",
|
|
52
|
+
"esbuild": "^0.28.2",
|
|
53
|
+
"hono": "^4.11.9",
|
|
54
|
+
"jsdom": "^30.1.1",
|
|
55
|
+
"react": "^19.3.0",
|
|
56
|
+
"react-dom": "^19.3.0",
|
|
57
|
+
"sonner": "^1.7.4",
|
|
58
|
+
"tailwind-merge": "^3.4.0",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vaul": "^1.1.2",
|
|
61
|
+
"vitest": "^5.0.1"
|
|
62
|
+
},
|
|
63
|
+
"description": "Sidebar thread list that shows each thread's state, harness and child threads at a glance.",
|
|
64
|
+
"license": "MIT",
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/gperezmz/bb-plugins.git",
|
|
68
|
+
"directory": "plugins/thread-glance"
|
|
69
|
+
},
|
|
70
|
+
"keywords": [
|
|
71
|
+
"bb-plugin"
|
|
72
|
+
],
|
|
73
|
+
"scripts": {
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"typecheck": "tsc --noEmit",
|
|
76
|
+
"build": "bb plugin build",
|
|
77
|
+
"notices": "node scripts/third-party-notices.mjs",
|
|
78
|
+
"perf": "vitest run --config perf/vitest.config.ts"
|
|
79
|
+
},
|
|
80
|
+
"files": [
|
|
81
|
+
"dist",
|
|
82
|
+
"skills",
|
|
83
|
+
"server.ts",
|
|
84
|
+
"tsconfig.json",
|
|
85
|
+
"THIRD_PARTY_NOTICES.md",
|
|
86
|
+
"app.tsx",
|
|
87
|
+
"server",
|
|
88
|
+
"shared",
|
|
89
|
+
"features",
|
|
90
|
+
"components",
|
|
91
|
+
"lib",
|
|
92
|
+
"hooks",
|
|
93
|
+
"types",
|
|
94
|
+
"!**/*.test.*"
|
|
95
|
+
]
|
|
96
|
+
}
|
package/server/cli.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `bb thread-glance prefs …`, the same commands and errors as bb's
|
|
2
|
+
// `bb thread-list prefs …`. Writes go through the store, so they publish.
|
|
3
|
+
import { PluginCliError, cliCommand, defineCli, type PluginCliRegistration } from "@get-bb/plugin-sdk";
|
|
4
|
+
import {
|
|
5
|
+
PREFERENCE_KEYS,
|
|
6
|
+
describePreference,
|
|
7
|
+
isPreferenceKey,
|
|
8
|
+
type PreferenceKey,
|
|
9
|
+
} from "../shared/preferences";
|
|
10
|
+
import { PreferenceValidationError, type PreferenceStore } from "./preference-store";
|
|
11
|
+
|
|
12
|
+
/** Parses a CLI value as JSON; a bare word such as `tree` is read as a string. */
|
|
13
|
+
export function parseCliValue(raw: string): unknown {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(raw) as unknown;
|
|
16
|
+
} catch {
|
|
17
|
+
return raw;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requirePreferenceKey(key: string): PreferenceKey {
|
|
22
|
+
if (isPreferenceKey(key)) return key;
|
|
23
|
+
throw new PluginCliError(`Unknown preference: ${key}`, {
|
|
24
|
+
code: "unknown_preference",
|
|
25
|
+
hint: `Known preferences: ${PREFERENCE_KEYS.join(", ")}.`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const jsonOption = { type: "boolean", description: "Emit machine-readable JSON" } as const;
|
|
30
|
+
const keyPositional = { name: "key", description: "Preference name", required: true } as const;
|
|
31
|
+
|
|
32
|
+
export function createCli(store: PreferenceStore): PluginCliRegistration {
|
|
33
|
+
function keyValueOutput(key: PreferenceKey, value: unknown, json: boolean): string {
|
|
34
|
+
return json ? JSON.stringify({ key, value }) : `${key} = ${JSON.stringify(value)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return defineCli({
|
|
38
|
+
name: "thread-glance",
|
|
39
|
+
summary: "Inspect and change the Thread Glance sidebar's layout preferences",
|
|
40
|
+
description:
|
|
41
|
+
"Organization, sort, group order, hidden and collapsed groups, child nesting and folding for the Thread Glance sidebar. Values are JSON; a bare word is read as a string.",
|
|
42
|
+
commands: {
|
|
43
|
+
"prefs list": cliCommand({
|
|
44
|
+
summary: "List every preference and its current value",
|
|
45
|
+
options: { json: jsonOption },
|
|
46
|
+
async run(input) {
|
|
47
|
+
const preferences = await store.readAll();
|
|
48
|
+
if (input.options.json) return { exitCode: 0, stdout: JSON.stringify(preferences) };
|
|
49
|
+
const lines = PREFERENCE_KEYS.map(
|
|
50
|
+
(key) => `${key}\t${JSON.stringify(preferences[key])}\t${describePreference(key)}`,
|
|
51
|
+
);
|
|
52
|
+
return { exitCode: 0, stdout: lines.join("\n") };
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
"prefs get": cliCommand({
|
|
56
|
+
summary: "Print one preference",
|
|
57
|
+
positionals: [keyPositional],
|
|
58
|
+
options: { json: jsonOption },
|
|
59
|
+
async run(input) {
|
|
60
|
+
const key = requirePreferenceKey(input.positionals.key);
|
|
61
|
+
const value = await store.read(key);
|
|
62
|
+
return {
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
stdout: input.options.json ? JSON.stringify({ key, value }) : JSON.stringify(value),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
"prefs set": cliCommand({
|
|
69
|
+
summary: "Set one preference",
|
|
70
|
+
positionals: [
|
|
71
|
+
keyPositional,
|
|
72
|
+
{
|
|
73
|
+
name: "value",
|
|
74
|
+
description: `JSON value, e.g. '"machine"' or '["pinned","threads"]'`,
|
|
75
|
+
required: true,
|
|
76
|
+
},
|
|
77
|
+
] as const,
|
|
78
|
+
options: { json: jsonOption },
|
|
79
|
+
async run(input) {
|
|
80
|
+
const key = requirePreferenceKey(input.positionals.key);
|
|
81
|
+
try {
|
|
82
|
+
const value = await store.write(key, parseCliValue(input.positionals.value));
|
|
83
|
+
return { exitCode: 0, stdout: keyValueOutput(key, value, input.options.json === true) };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof PreferenceValidationError) {
|
|
86
|
+
throw new PluginCliError(error.message, {
|
|
87
|
+
code: "invalid_preference_value",
|
|
88
|
+
hint: describePreference(key),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
"prefs reset": cliCommand({
|
|
96
|
+
summary: "Restore one preference to its default",
|
|
97
|
+
positionals: [keyPositional],
|
|
98
|
+
options: { json: jsonOption },
|
|
99
|
+
async run(input) {
|
|
100
|
+
const key = requirePreferenceKey(input.positionals.key);
|
|
101
|
+
const value = await store.reset(key);
|
|
102
|
+
return { exitCode: 0, stdout: keyValueOutput(key, value, input.options.json === true) };
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The text of a thread's failed note. `thread.failed` often carries
|
|
2
|
+
// `error: null`; the provider's message is in the thread's `provider/error`
|
|
3
|
+
// event, and `turn.failed` carries a structured `errorInfo`.
|
|
4
|
+
import type { BbPluginApi, PluginTurnFailedEvent } from "@get-bb/plugin-sdk";
|
|
5
|
+
import { noteText } from "./notes";
|
|
6
|
+
|
|
7
|
+
type ErrorInfo = NonNullable<PluginTurnFailedEvent["errorInfo"]>;
|
|
8
|
+
|
|
9
|
+
/** Rows read from the end of the event log; the failing turn's error is near it. */
|
|
10
|
+
const EVENT_TAIL_LIMIT = 20;
|
|
11
|
+
const GENERIC_ERRORS = new Set(["error", "failed", "provider error", "turn failed", "unknown error"]);
|
|
12
|
+
|
|
13
|
+
/** Whether an error string says nothing beyond "it failed". */
|
|
14
|
+
export function isGenericError(text: string | null | undefined): boolean {
|
|
15
|
+
if (text === null || text === undefined) return true;
|
|
16
|
+
const normalized = text.trim().toLowerCase().replace(/[.!]+$/, "");
|
|
17
|
+
// bb's own "Command thread.start failed" names the step, not the cause.
|
|
18
|
+
return normalized === "" || GENERIC_ERRORS.has(normalized) || /^command [\w./:-]+ failed$/.test(normalized);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A short label for an error category, or null when it says nothing. */
|
|
22
|
+
export function errorInfoLabel(info: ErrorInfo | null | undefined): string | null {
|
|
23
|
+
if (!info) return null;
|
|
24
|
+
const status = info.httpStatusCode === null ? "" : ` (${info.httpStatusCode})`;
|
|
25
|
+
switch (info.category) {
|
|
26
|
+
case "rate-limit":
|
|
27
|
+
return `Rate limited${status}`;
|
|
28
|
+
case "unauthorized":
|
|
29
|
+
return "Not signed in";
|
|
30
|
+
case "billing":
|
|
31
|
+
case "budget-exceeded":
|
|
32
|
+
return "Out of credits";
|
|
33
|
+
case "context-window-exceeded":
|
|
34
|
+
return "Context window full";
|
|
35
|
+
case "unknown":
|
|
36
|
+
return status === "" ? null : `Error${status}`;
|
|
37
|
+
default: {
|
|
38
|
+
const words = info.category.replace(/-/g, " ");
|
|
39
|
+
return `${words.charAt(0).toUpperCase()}${words.slice(1)}${status}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The fields of a `provider/error` event the note reads. */
|
|
45
|
+
export interface ProviderErrorData {
|
|
46
|
+
message?: string;
|
|
47
|
+
detail?: string;
|
|
48
|
+
errorInfo?: ErrorInfo;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An event row as read here: newest first, `provider/error` and `turn/started` only. */
|
|
52
|
+
export interface TailRow {
|
|
53
|
+
type: string;
|
|
54
|
+
scope?: { kind: string; turnId?: string };
|
|
55
|
+
data?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The newest `provider/error` of the failing turn, from rows newest first.
|
|
60
|
+
*
|
|
61
|
+
* With a `turnId`, only an error scoped to that turn counts. Without one,
|
|
62
|
+
* the search stops at the newest `turn/started`, so an error from an earlier
|
|
63
|
+
* turn is not reported for this failure.
|
|
64
|
+
*/
|
|
65
|
+
export function latestProviderError(
|
|
66
|
+
rows: readonly TailRow[],
|
|
67
|
+
turnId: string | null,
|
|
68
|
+
): ProviderErrorData | null {
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
if (row.type === "turn/started" && turnId === null) return null;
|
|
71
|
+
if (row.type !== "provider/error" && row.type !== "system/error") continue;
|
|
72
|
+
// A system error (a provider that never started) belongs to no turn.
|
|
73
|
+
if (turnId !== null && row.scope?.turnId !== undefined && row.scope.turnId !== turnId) continue;
|
|
74
|
+
return (row.data ?? {}) as ProviderErrorData;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The failure text from what is known, most specific first: a non-generic
|
|
81
|
+
* `error`, the provider error's `detail`, then its `message`, then a label
|
|
82
|
+
* from `errorInfo`. Null when none says more than "it failed".
|
|
83
|
+
*/
|
|
84
|
+
export function failureText(input: {
|
|
85
|
+
error: string | null;
|
|
86
|
+
providerError: ProviderErrorData | null;
|
|
87
|
+
errorInfo: ErrorInfo | null;
|
|
88
|
+
}): string | null {
|
|
89
|
+
const candidates = [
|
|
90
|
+
input.error,
|
|
91
|
+
input.providerError?.detail,
|
|
92
|
+
input.providerError?.message,
|
|
93
|
+
errorInfoLabel(input.errorInfo ?? input.providerError?.errorInfo),
|
|
94
|
+
];
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
if (!isGenericError(candidate)) {
|
|
97
|
+
const text = noteText(candidate as string);
|
|
98
|
+
if (text !== "") return text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves a failure's text, reading the tail of the thread's event log when
|
|
106
|
+
* `error` is generic. A failed read is logged and treated as no event.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveFailureText(
|
|
109
|
+
bb: Pick<BbPluginApi, "sdk" | "log">,
|
|
110
|
+
failure: {
|
|
111
|
+
threadId: string;
|
|
112
|
+
error: string | null;
|
|
113
|
+
errorInfo: ErrorInfo | null;
|
|
114
|
+
turnId: string | null;
|
|
115
|
+
},
|
|
116
|
+
): Promise<string | null> {
|
|
117
|
+
let providerError: ProviderErrorData | null = null;
|
|
118
|
+
if (isGenericError(failure.error)) {
|
|
119
|
+
try {
|
|
120
|
+
const rows = await bb.sdk.threads.events.list({
|
|
121
|
+
threadId: failure.threadId,
|
|
122
|
+
types: ["provider/error", "system/error", "turn/started"],
|
|
123
|
+
order: "desc",
|
|
124
|
+
limit: String(EVENT_TAIL_LIMIT),
|
|
125
|
+
});
|
|
126
|
+
providerError = latestProviderError(rows as readonly TailRow[], failure.turnId);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
bb.log.warn(
|
|
129
|
+
`could not read events of ${failure.threadId} for its failure: ${
|
|
130
|
+
error instanceof Error ? error.message : String(error)
|
|
131
|
+
}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return failureText({ error: failure.error, providerError, errorInfo: failure.errorInfo });
|
|
136
|
+
}
|
package/server/import.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// First-run import of bb's own thread-list preferences.
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
4
|
+
import {
|
|
5
|
+
mapBbPreferences,
|
|
6
|
+
preferenceDefault,
|
|
7
|
+
type PreferenceKey,
|
|
8
|
+
} from "../shared/preferences";
|
|
9
|
+
import type { PreferenceStore } from "./preference-store";
|
|
10
|
+
|
|
11
|
+
export const IMPORT_MARKER_KEY = "migration:import:v1";
|
|
12
|
+
const CLI_TIMEOUT_MS = 5_000;
|
|
13
|
+
const CLI_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export type ImportSource = "local-storage" | "cli" | "none";
|
|
16
|
+
|
|
17
|
+
export interface ImportResult {
|
|
18
|
+
status: "already-imported" | "imported" | "defaults";
|
|
19
|
+
source: ImportSource | null;
|
|
20
|
+
keys: PreferenceKey[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads bb's preferences from its CLI and returns the parsed JSON, or null
|
|
25
|
+
* when the command fails or prints something that is not JSON.
|
|
26
|
+
*/
|
|
27
|
+
export type BbCliReader = () => Promise<unknown>;
|
|
28
|
+
|
|
29
|
+
export interface ImportDeps {
|
|
30
|
+
kv: BbPluginApi["storage"]["kv"];
|
|
31
|
+
log: BbPluginApi["log"];
|
|
32
|
+
store: PreferenceStore;
|
|
33
|
+
readBbCli: BbCliReader;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns bb's preference object from a mirror or CLI value, or null when it
|
|
38
|
+
* holds no preferences.
|
|
39
|
+
*
|
|
40
|
+
* bb 0.43 writes both as a flat key-to-value object. A JSON string and a
|
|
41
|
+
* `{ preferences }` wrapper (the shape of bb's `listPreferences` RPC) are
|
|
42
|
+
* unwrapped too, so an app that sends the raw localStorage string or a newer
|
|
43
|
+
* bb that wraps the object still imports.
|
|
44
|
+
*/
|
|
45
|
+
export function unwrapBbPreferences(raw: unknown): Record<string, unknown> | null {
|
|
46
|
+
let value = raw;
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
try {
|
|
49
|
+
value = JSON.parse(value) as unknown;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
55
|
+
const record = value as Record<string, unknown>;
|
|
56
|
+
const inner = record.preferences;
|
|
57
|
+
if (inner !== null && typeof inner === "object" && !Array.isArray(inner)) {
|
|
58
|
+
return inner as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The bb binary: `BB_CLI` when the server has it, else `bb` on PATH. */
|
|
64
|
+
export function bbCliPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
65
|
+
const configured = env.BB_CLI?.trim();
|
|
66
|
+
return configured ? configured : "bb";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Runs `bb thread-list prefs list --json` with a 5 s timeout. */
|
|
70
|
+
export function createBbCliReader(
|
|
71
|
+
log: BbPluginApi["log"],
|
|
72
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
73
|
+
): BbCliReader {
|
|
74
|
+
return () =>
|
|
75
|
+
new Promise((resolve) => {
|
|
76
|
+
const file = bbCliPath(env);
|
|
77
|
+
execFile(
|
|
78
|
+
file,
|
|
79
|
+
["thread-list", "prefs", "list", "--json"],
|
|
80
|
+
{ timeout: CLI_TIMEOUT_MS, maxBuffer: CLI_MAX_BUFFER_BYTES, encoding: "utf8" },
|
|
81
|
+
(error, stdout) => {
|
|
82
|
+
if (error) {
|
|
83
|
+
log.warn(`could not run ${file} thread-list prefs list: ${error.message}`);
|
|
84
|
+
resolve(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
resolve(JSON.parse(stdout) as unknown);
|
|
89
|
+
} catch {
|
|
90
|
+
log.warn(`${file} thread-list prefs list printed output that is not JSON`);
|
|
91
|
+
resolve(null);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Imports bb's preferences once, from the first source that holds any:
|
|
100
|
+
* `bbMirror` (bb's localStorage mirror, sent by the app), then bb's CLI, then
|
|
101
|
+
* nothing. A key that already has a kv row keeps it, and a value equal to our
|
|
102
|
+
* default is not written. The marker is set whichever source won, so a later
|
|
103
|
+
* call returns `already-imported`.
|
|
104
|
+
*/
|
|
105
|
+
export async function importPreferences(
|
|
106
|
+
deps: ImportDeps,
|
|
107
|
+
bbMirror: unknown,
|
|
108
|
+
): Promise<ImportResult> {
|
|
109
|
+
if ((await deps.kv.get<unknown>(IMPORT_MARKER_KEY)) !== undefined) {
|
|
110
|
+
return { status: "already-imported", source: null, keys: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let source: ImportSource = "none";
|
|
114
|
+
let mapped: Record<string, unknown> = {};
|
|
115
|
+
const fromMirror = bbMirror === null ? null : unwrapBbPreferences(bbMirror);
|
|
116
|
+
const mirrorMapped = fromMirror === null ? {} : mapBbPreferences(fromMirror);
|
|
117
|
+
if (Object.keys(mirrorMapped).length > 0) {
|
|
118
|
+
source = "local-storage";
|
|
119
|
+
mapped = mirrorMapped;
|
|
120
|
+
} else {
|
|
121
|
+
const cli = unwrapBbPreferences(await deps.readBbCli());
|
|
122
|
+
const cliMapped = cli === null ? {} : mapBbPreferences(cli);
|
|
123
|
+
if (Object.keys(cliMapped).length > 0) {
|
|
124
|
+
source = "cli";
|
|
125
|
+
mapped = cliMapped;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const keys: PreferenceKey[] = [];
|
|
130
|
+
for (const [key, value] of Object.entries(mapped) as [PreferenceKey, unknown][]) {
|
|
131
|
+
if (await deps.store.isStored(key)) continue;
|
|
132
|
+
if (JSON.stringify(value) === JSON.stringify(preferenceDefault(key))) continue;
|
|
133
|
+
await deps.store.write(key, value);
|
|
134
|
+
keys.push(key);
|
|
135
|
+
}
|
|
136
|
+
await deps.kv.set(IMPORT_MARKER_KEY, { source, keys, at: Date.now() });
|
|
137
|
+
|
|
138
|
+
deps.log.info(
|
|
139
|
+
source === "none"
|
|
140
|
+
? "no bb thread-list preferences found; using the defaults"
|
|
141
|
+
: `imported bb thread-list preferences from ${source}: ${keys.join(", ") || "none differ from the defaults"}`,
|
|
142
|
+
);
|
|
143
|
+
return { status: source === "none" ? "defaults" : "imported", source, keys };
|
|
144
|
+
}
|