@howaboua/pi-agent-board 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ MessageRenderer,
5
+ SessionEntry,
6
+ Theme,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
9
+ import type { BoardParams } from "./contract.js";
10
+
11
+ const BOARD_POST_MARKER = "shepherdr-board-post-marker";
12
+
13
+ export function recordBoardActivityMarker(
14
+ pi: ExtensionAPI,
15
+ ctx: ExtensionContext,
16
+ params: BoardParams,
17
+ value: unknown,
18
+ requestId: string,
19
+ ): void {
20
+ const labels: Partial<Record<BoardParams["action"], string>> = {
21
+ create_channel: "Created board channel",
22
+ post: params.thread_id ? "Posted board reply" : "Created board thread",
23
+ read_thread: "Read board thread",
24
+ read_post: "Read board post",
25
+ list_boards: "Read board list",
26
+ get_channels: "Read board channels",
27
+ list_threads: "Read board threads",
28
+ search_posts: "Searched board posts",
29
+ };
30
+ const label = labels[params.action];
31
+ if (!label || !value || typeof value !== "object") return;
32
+ const result = value as Record<string, unknown>;
33
+ const root = result["root_post"];
34
+ const metadata =
35
+ root && typeof root === "object"
36
+ ? (root as Record<string, unknown>)
37
+ : result;
38
+ const field = (key: string) =>
39
+ typeof metadata[key] === "string" ? metadata[key] : undefined;
40
+ if (
41
+ ctx.sessionManager
42
+ .getBranch()
43
+ .some(
44
+ (entry) =>
45
+ entry.type === "custom" &&
46
+ entry.customType === BOARD_POST_MARKER &&
47
+ entry.data &&
48
+ typeof entry.data === "object" &&
49
+ "requestId" in entry.data &&
50
+ entry.data.requestId === requestId,
51
+ )
52
+ )
53
+ return;
54
+ // Owner acknowledgements reach the sender here, never the subscription inbox.
55
+ pi.appendEntry(BOARD_POST_MARKER, {
56
+ operation: params.action,
57
+ label,
58
+ requestId,
59
+ boardId: params.board_id,
60
+ messageId: field("message_id") ?? params.message_id,
61
+ threadId: field("thread_id") ?? params.thread_id,
62
+ channelName:
63
+ field("channel_name") ?? params.channel_name ?? params.new_channel_name,
64
+ });
65
+ }
66
+
67
+ const BOARD_NOTICE = "shepherdr-board-post";
68
+ const BOARD_CATCHUP = "shepherdr-board-catchup";
69
+
70
+ interface BoardRow {
71
+ id: string;
72
+ label: string;
73
+ detail: () => string;
74
+ }
75
+
76
+ function boardRow(entry: SessionEntry): BoardRow | undefined {
77
+ if (
78
+ entry.type === "custom_message" &&
79
+ (entry.customType === BOARD_NOTICE || entry.customType === BOARD_CATCHUP)
80
+ ) {
81
+ return {
82
+ id: entry.id,
83
+ label: "Board notice received",
84
+ detail: () =>
85
+ typeof entry.content === "string"
86
+ ? entry.content
87
+ : JSON.stringify(entry.content),
88
+ };
89
+ }
90
+ if (entry.type === "custom" && entry.customType === BOARD_POST_MARKER) {
91
+ const data =
92
+ entry.data && typeof entry.data === "object"
93
+ ? (entry.data as Record<string, unknown>)
94
+ : undefined;
95
+ const label =
96
+ typeof data?.["label"] === "string" ? data["label"] : "Board action";
97
+ const channel =
98
+ typeof data?.["channelName"] === "string" ? data["channelName"] : "";
99
+ return {
100
+ id: entry.id,
101
+ label: [label, channel]
102
+ .filter(Boolean)
103
+ .join(" · ")
104
+ .replace(/[\r\n\t\x00-\x1f\x7f]/g, " "),
105
+ detail: () => JSON.stringify(entry.data, null, 2) ?? "",
106
+ };
107
+ }
108
+ if (
109
+ entry.type === "message" &&
110
+ entry.message.role === "custom" &&
111
+ (entry.message.customType === BOARD_NOTICE ||
112
+ entry.message.customType === BOARD_CATCHUP)
113
+ ) {
114
+ const content = entry.message.content;
115
+ return {
116
+ id: entry.id,
117
+ label: "Board notice received",
118
+ detail: () =>
119
+ typeof content === "string" ? content : JSON.stringify(content),
120
+ };
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /** Project existing branch entries only. No new history or model delivery. */
126
+ export function registerBoardActivityRenderer(pi: ExtensionAPI): void {
127
+ let sessionManager: ExtensionContext["sessionManager"] | undefined;
128
+ let generation = 0;
129
+ const restore = (_event: unknown, ctx: ExtensionContext) => {
130
+ sessionManager = ctx.sessionManager;
131
+ generation++;
132
+ };
133
+ pi.on("session_start", restore);
134
+ pi.on("session_tree", restore);
135
+ pi.on("session_compact", restore);
136
+ pi.on("session_shutdown", () => {
137
+ sessionManager = undefined;
138
+ cache = undefined;
139
+ });
140
+
141
+ interface BoardGroup {
142
+ rows: BoardRow[];
143
+ summary: string;
144
+ details?: string;
145
+ }
146
+ let cache:
147
+ | {
148
+ leaf: string | null;
149
+ generation: number;
150
+ groups: Map<string, BoardGroup>;
151
+ notices: Map<unknown, string>;
152
+ }
153
+ | undefined;
154
+ const projection = () => {
155
+ const leaf = sessionManager?.getLeafId() ?? null;
156
+ if (cache?.leaf === leaf && cache.generation === generation) return cache;
157
+ const groups = new Map<string, BoardGroup>();
158
+ const notices = new Map<unknown, string>();
159
+ let rows: BoardRow[] = [];
160
+ let running = false;
161
+ const finish = () => {
162
+ if (!rows.length) return;
163
+ const counts = new Map<string, number>();
164
+ for (const row of rows)
165
+ counts.set(row.label, (counts.get(row.label) ?? 0) + 1);
166
+ const group: BoardGroup = {
167
+ rows,
168
+ summary:
169
+ "Board · " +
170
+ [...counts]
171
+ .map(([label, count]) => label + (count > 1 ? ` ×${count}` : ""))
172
+ .join(" · "),
173
+ };
174
+ for (const row of rows) groups.set(row.id, group);
175
+ };
176
+ for (const entry of sessionManager?.getBranch() ?? []) {
177
+ if (
178
+ entry.type === "message" &&
179
+ entry.message.role === "user" &&
180
+ !running
181
+ ) {
182
+ finish();
183
+ rows = [];
184
+ running = true;
185
+ }
186
+ if (entry.type === "message" && entry.message.role === "assistant") {
187
+ const message = entry.message;
188
+ // Interrupted partial tool calls do not keep the next kickoff in this run.
189
+ running =
190
+ !["aborted", "error", "length"].includes(message.stopReason) &&
191
+ (message.stopReason === "toolUse" ||
192
+ message.content.some((block) => block.type === "toolCall"));
193
+ }
194
+ const row = boardRow(entry);
195
+ if (!row) continue;
196
+ rows.push(row);
197
+ if (entry.type === "custom_message") notices.set(entry.content, entry.id);
198
+ else if (entry.type === "message" && entry.message.role === "custom")
199
+ notices.set(entry.message.content, entry.id);
200
+ }
201
+ finish();
202
+ cache = { leaf, generation, groups, notices };
203
+ return cache;
204
+ };
205
+ const component = (
206
+ identity: string | { content: unknown },
207
+ expanded: boolean,
208
+ theme: Theme,
209
+ outputPad: number,
210
+ pending?: string,
211
+ ) =>
212
+ new (class extends Text {
213
+ private readonly createdGeneration = generation;
214
+ private persisted = false;
215
+ override render(width: number): string[] {
216
+ if (!sessionManager) return [];
217
+ const padding = Math.min(
218
+ outputPad,
219
+ Math.max(0, Math.floor((width - 1) / 2)),
220
+ );
221
+ const contentWidth = Math.max(1, width - padding * 2);
222
+ const current = projection();
223
+ const ownId =
224
+ typeof identity === "string"
225
+ ? identity
226
+ : current.notices.get(identity.content);
227
+ const group = ownId ? current.groups.get(ownId) : undefined;
228
+ if (group) this.persisted = true;
229
+ // Streamed custom messages can render before message_end persists them.
230
+ if (
231
+ !group &&
232
+ pending &&
233
+ !this.persisted &&
234
+ this.createdGeneration === generation
235
+ ) {
236
+ this.setText(
237
+ theme.style(
238
+ truncateToWidth("Board · Notice received", contentWidth),
239
+ { fg: "syntaxString", dim: true },
240
+ ) + (expanded ? "\n" + pending : ""),
241
+ );
242
+ return super.render(width);
243
+ }
244
+ if (!group || group.rows.at(-1)?.id !== ownId) return [];
245
+ const heading = theme.style(
246
+ truncateToWidth(group.summary, contentWidth),
247
+ { fg: "syntaxString", dim: true },
248
+ );
249
+ if (expanded)
250
+ group.details ??= group.rows
251
+ .map((row) => row.label + "\n" + row.detail())
252
+ .join("\n\n");
253
+ this.setText(heading + (expanded ? "\n" + group.details : ""));
254
+ return super.render(width);
255
+ }
256
+ })("", outputPad, 0);
257
+ // Pi's entry renderer contract does not expose outputPad; use its default inset.
258
+ pi.registerEntryRenderer(BOARD_POST_MARKER, (entry, { expanded }, theme) =>
259
+ component(entry.id, expanded, theme, 1),
260
+ );
261
+ const incoming: MessageRenderer = (message, { expanded, outputPad }, theme) =>
262
+ component(
263
+ { content: message.content },
264
+ expanded,
265
+ theme,
266
+ outputPad,
267
+ typeof message.content === "string"
268
+ ? message.content
269
+ : JSON.stringify(message.content),
270
+ );
271
+ pi.registerMessageRenderer(BOARD_NOTICE, incoming);
272
+ pi.registerMessageRenderer(BOARD_CATCHUP, incoming);
273
+ }
package/src/archive.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { BoardParams } from "./contract.js";
3
+ import { agentPath, MUTATIONS } from "./contract.js";
4
+ import type { BoardBinding } from "./identity.js";
5
+ import { page } from "./paging.js";
6
+ import { boundedBoardRead, checkMutationBudget } from "./response.js";
7
+
8
+ export async function readSubscribedUpdates(
9
+ caller: BoardBinding,
10
+ seen: string[],
11
+ ) {
12
+ if (!existsSync(caller.databasePath)) return [];
13
+ const { BoardStore } = await import("./store.js");
14
+ const store = new BoardStore(caller.databasePath, false);
15
+ try {
16
+ return store.all<{ message_id: string; thread_id: string }>(
17
+ `SELECT p.id AS message_id,p.root AS thread_id FROM posts p
18
+ WHERE p.board_id=? AND p.author<>? AND NOT EXISTS
19
+ (SELECT 1 FROM json_each(?) WHERE value=p.id) AND EXISTS
20
+ (SELECT 1 FROM subscriptions s WHERE s.board_id=p.board_id
21
+ AND s.agent=? AND s.enabled=1 AND s.target=CASE WHEN p.id=p.root
22
+ THEN 'channel:'||p.channel ELSE 'thread:'||p.root END)
23
+ ORDER BY p.seq ASC LIMIT 50`,
24
+ caller.boardId,
25
+ caller.agentName,
26
+ JSON.stringify(seen),
27
+ caller.agentName,
28
+ );
29
+ } finally {
30
+ store.close();
31
+ }
32
+ }
33
+
34
+ export async function executeArchive(
35
+ caller: BoardBinding,
36
+ memberNames: readonly string[],
37
+ params: BoardParams,
38
+ requestId: string,
39
+ ) {
40
+ const write = MUTATIONS.has(params.action);
41
+ if (write) checkMutationBudget(caller.agentName, params);
42
+ let storage: typeof import("./store.js");
43
+ try {
44
+ storage = await import("./store.js");
45
+ } catch (error) {
46
+ if (
47
+ error instanceof Error &&
48
+ "code" in error &&
49
+ error.code === "ERR_UNKNOWN_BUILTIN_MODULE"
50
+ )
51
+ throw new Error(
52
+ "Message boards require Node.js 22.13 or newer with SQLite support",
53
+ { cause: error },
54
+ );
55
+ throw error;
56
+ }
57
+ storage.validateMutation(params);
58
+ if (write) {
59
+ for (const value of [
60
+ ...(params.agents_to_notify ?? []),
61
+ ...(params.target_agent === undefined ? [] : [params.target_agent]),
62
+ ]) {
63
+ const target = agentPath(value, caller.agentName);
64
+ if (!memberNames.includes(target))
65
+ throw new Error(`Agent not bound to this tree: ${target}`);
66
+ }
67
+ }
68
+ const exists = existsSync(caller.databasePath);
69
+ if (
70
+ !exists &&
71
+ params.action === "search_posts" &&
72
+ params.after_message_id !== undefined
73
+ )
74
+ throw new Error("Post not found in this board");
75
+ if (
76
+ !exists &&
77
+ (!write ||
78
+ (params.action !== "create_channel" &&
79
+ params.new_channel_name === undefined))
80
+ ) {
81
+ if (["get_channels", "search_posts", "list_boards"].includes(params.action))
82
+ return { value: page([], params), recipients: [] };
83
+ throw new Error("This board has no channels or posts yet");
84
+ }
85
+ const store = new storage.BoardStore(caller.databasePath, write);
86
+ try {
87
+ const scope = {
88
+ boardId: caller.boardId,
89
+ rootSessionId: caller.rootSessionId,
90
+ ownerFolder: caller.ownerFolder,
91
+ callerSessionId: caller.sessionId,
92
+ agentName: caller.agentName,
93
+ members: memberNames,
94
+ };
95
+ return write
96
+ ? store.execute(scope, params, requestId)
97
+ : boundedBoardRead(params, (request) =>
98
+ store.execute(scope, request, requestId),
99
+ );
100
+ } finally {
101
+ store.close();
102
+ }
103
+ }
@@ -0,0 +1,181 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { boardBriefing } from "./briefing.js";
7
+ import {
8
+ contextBriefingWindow,
9
+ recordContextBriefing,
10
+ registerContextBriefing,
11
+ } from "./context-briefing.js";
12
+ import { binding } from "./identity.js";
13
+
14
+ const OWNER = "shepherdr-board";
15
+ const STATE = "shepherdr-board-awareness";
16
+ interface AwarenessState {
17
+ sessionId: string;
18
+ signature: string;
19
+ epoch: string;
20
+ }
21
+
22
+ /** Board state is sampled once per selected window/membership, never on a timer. */
23
+ export class BoardAwareness {
24
+ private readonly pi: ExtensionAPI;
25
+ private readonly read: (ctx: ExtensionContext) => Promise<unknown>;
26
+ private readonly availability: (
27
+ ctx: ExtensionContext,
28
+ error?: unknown,
29
+ ) => void;
30
+ constructor(
31
+ pi: ExtensionAPI,
32
+ read: (ctx: ExtensionContext) => Promise<unknown>,
33
+ availability: (ctx: ExtensionContext, error?: unknown) => void,
34
+ privateBriefing: () => typeof boardBriefing | undefined,
35
+ ) {
36
+ this.pi = pi;
37
+ this.read = read;
38
+ this.availability = availability;
39
+ this.privateBriefing = privateBriefing;
40
+ registerContextBriefing(
41
+ pi,
42
+ "board",
43
+ (ctx) => binding(ctx).enabled,
44
+ (ctx, windowId) => this.record(ctx, windowId),
45
+ );
46
+ }
47
+ private readonly privateBriefing: () => typeof boardBriefing | undefined;
48
+
49
+ refresh(ctx: ExtensionContext): AwarenessState {
50
+ const own = binding(ctx);
51
+ const signature = JSON.stringify([
52
+ own.boardId,
53
+ own.rootSessionId,
54
+ own.agentName,
55
+ own.upstream,
56
+ own.enabled,
57
+ ]);
58
+ const saved = ctx.sessionManager
59
+ .getEntries()
60
+ .findLast(
61
+ (entry) =>
62
+ entry.type === "custom" &&
63
+ entry.customType === STATE &&
64
+ readState(entry.data).sessionId === own.sessionId,
65
+ );
66
+ if (saved?.type === "custom") {
67
+ const state = readState(saved.data);
68
+ if (state.signature === signature) return state;
69
+ }
70
+ const state = { sessionId: own.sessionId, signature, epoch: randomUUID() };
71
+ this.pi.appendEntry(STATE, state);
72
+ return state;
73
+ }
74
+ seen(ctx: ExtensionContext): string[] {
75
+ const own = binding(ctx);
76
+ return ctx.sessionManager.getEntries().flatMap((entry) => {
77
+ if (
78
+ entry.type !== "custom" ||
79
+ entry.customType !== "shepherdr-board-seen"
80
+ )
81
+ return [];
82
+ const data = entry.data as {
83
+ boardId?: string;
84
+ agentName?: string;
85
+ ids?: unknown;
86
+ };
87
+ return data?.boardId === own.boardId &&
88
+ data.agentName === own.agentName &&
89
+ Array.isArray(data.ids)
90
+ ? data.ids.filter((id): id is string => typeof id === "string")
91
+ : [];
92
+ });
93
+ }
94
+ markSeen(ctx: ExtensionContext, ids: string[]) {
95
+ if (!ids.length) return;
96
+ const own = binding(ctx);
97
+ this.pi.appendEntry("shepherdr-board-seen", {
98
+ boardId: own.boardId,
99
+ agentName: own.agentName,
100
+ ids,
101
+ });
102
+ }
103
+ markRead(ctx: ExtensionContext, value: unknown) {
104
+ if (!value || typeof value !== "object") return;
105
+ const record = value as {
106
+ message_id?: unknown;
107
+ root_post?: unknown;
108
+ results?: unknown;
109
+ };
110
+ const posts = [
111
+ record,
112
+ record.root_post,
113
+ ...(Array.isArray(record.results) ? record.results : []),
114
+ ];
115
+ this.markSeen(
116
+ ctx,
117
+ posts.flatMap((post) =>
118
+ post &&
119
+ typeof post === "object" &&
120
+ "message_id" in post &&
121
+ typeof post.message_id === "string"
122
+ ? [post.message_id]
123
+ : [],
124
+ ),
125
+ );
126
+ }
127
+
128
+ private async record(ctx: ExtensionContext, windowId?: string) {
129
+ const own = binding(ctx);
130
+ const state = this.refresh(ctx);
131
+ if (!own.enabled) return;
132
+ const window = contextBriefingWindow(ctx, windowId);
133
+ const key = JSON.stringify([own.sessionId, window, state.epoch]);
134
+ await recordContextBriefing(this.pi, ctx, OWNER, key, async () => {
135
+ let population: "empty" | "populated" | "unavailable";
136
+ try {
137
+ const value = await this.read(ctx);
138
+ if (!binding(ctx).enabled) return;
139
+ if (
140
+ !value ||
141
+ typeof value !== "object" ||
142
+ !("results" in value) ||
143
+ !Array.isArray(value.results)
144
+ )
145
+ throw new Error("Invalid board population response");
146
+ population = value.results.length ? "populated" : "empty";
147
+ this.availability(ctx);
148
+ } catch (error) {
149
+ ctx.signal?.throwIfAborted();
150
+ population = "unavailable";
151
+ this.availability(ctx, error);
152
+ }
153
+ ctx.signal?.throwIfAborted();
154
+ if (!binding(ctx).enabled) return;
155
+ if (
156
+ binding(ctx).enabled !== own.enabled ||
157
+ this.refresh(ctx).epoch !== state.epoch
158
+ )
159
+ throw new Error("Board membership changed during its briefing");
160
+ return (this.privateBriefing() ?? boardBriefing)(
161
+ Boolean(own.upstream),
162
+ population,
163
+ );
164
+ });
165
+ }
166
+ }
167
+
168
+ function readState(value: unknown): AwarenessState {
169
+ if (
170
+ !value ||
171
+ typeof value !== "object" ||
172
+ !("sessionId" in value) ||
173
+ typeof value.sessionId !== "string" ||
174
+ !("signature" in value) ||
175
+ typeof value.signature !== "string" ||
176
+ !("epoch" in value) ||
177
+ typeof value.epoch !== "string"
178
+ )
179
+ throw new Error("Invalid saved board awareness");
180
+ return value as AwarenessState;
181
+ }
@@ -0,0 +1,19 @@
1
+ export function boardBriefing(
2
+ member: boolean,
3
+ population: "empty" | "populated" | "unavailable",
4
+ ) {
5
+ const state =
6
+ population === "populated"
7
+ ? "Read relevant board threads."
8
+ : population === "empty"
9
+ ? "Shared board available; no posts yet."
10
+ : "Shared board status unavailable. Check board help and retry reading when available.";
11
+ return [
12
+ state,
13
+ member ? "The root agent owns setup; follow your assigned task." : "",
14
+ "Post only new information that changes another agent's work: ownership, decisions, findings or blockers. Reply in existing threads; link rather than repeat. Keep personal checkpoints in notes. Skip acknowledgements, routine status and duplicated completion reports.",
15
+ "Channels group shared workstreams; threads group topics. Reuse a relevant channel, not a catch-all. Create a channel only for a distinct coordination need, never just to announce a task or result.",
16
+ ]
17
+ .filter(Boolean)
18
+ .join(" ");
19
+ }
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
+
14
+ const CONFIG_FILE = "pi-shepherdr.json";
15
+ export type BoardScope = "session" | "folder" | "global";
16
+
17
+ function object(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ function readDocument(path: string): Record<string, unknown> {
22
+ if (!existsSync(path)) return {};
23
+ try {
24
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
25
+ if (!object(value)) throw new Error("configuration must be an object");
26
+ if (value["board"] !== undefined && !object(value["board"]))
27
+ throw new Error("board must be an object");
28
+ return value;
29
+ } catch (error) {
30
+ throw new Error(`Could not read ${path}: ${String(error)}`, {
31
+ cause: error,
32
+ });
33
+ }
34
+ }
35
+
36
+ function readEnabled(path: string, scope: "folder" | "global") {
37
+ const document = readDocument(path);
38
+ const board = object(document["board"]) ? document["board"] : {};
39
+ const field = scope === "global" ? "enabledGlobally" : "enabled";
40
+ const wrongField = scope === "global" ? "enabled" : "enabledGlobally";
41
+ if (wrongField in board)
42
+ throw new Error(`${path}: use board.${field} for ${scope} enablement`);
43
+ const value = board[field];
44
+ if (value !== undefined && typeof value !== "boolean")
45
+ throw new Error(`${path}: board.${field} must be a boolean`);
46
+ return value;
47
+ }
48
+
49
+ function boardConfigPaths(folder: string) {
50
+ // Keep aliases on one write target so atomic saves do not replace symlinks.
51
+ function configPath(directory: string) {
52
+ const path = resolve(directory, CONFIG_FILE);
53
+ return existsSync(path) ? realpathSync(path) : path;
54
+ }
55
+ const global = configPath(getAgentDir());
56
+ const local = configPath(resolve(folder, ".pi"));
57
+ return {
58
+ global,
59
+ // The global config is never also a folder config.
60
+ folder: local === global ? undefined : local,
61
+ };
62
+ }
63
+
64
+ export function readBoardConfig(folder: string, defaultEnabled = false) {
65
+ const paths = boardConfigPaths(folder);
66
+ const global = readEnabled(paths.global, "global");
67
+ return {
68
+ paths,
69
+ folder: paths.folder ? readEnabled(paths.folder, "folder") : undefined,
70
+ global: global ?? defaultEnabled,
71
+ };
72
+ }
73
+
74
+ export function writeBoardConfig(
75
+ folder: string,
76
+ scope: "folder" | "global",
77
+ enabled: boolean | undefined,
78
+ ) {
79
+ if (scope === "global" && enabled === undefined)
80
+ throw new Error("Global enablement must be on or off");
81
+ const path = boardConfigPaths(folder)[scope];
82
+ if (!path)
83
+ throw new Error(
84
+ "This folder's config is the global config; use a session override instead",
85
+ );
86
+ readEnabled(path, scope);
87
+ const document = readDocument(path);
88
+ const board = object(document["board"]) ? { ...document["board"] } : {};
89
+ const field = scope === "global" ? "enabledGlobally" : "enabled";
90
+ if (enabled === undefined) delete board[field];
91
+ else board[field] = enabled;
92
+ if (enabled === undefined && !existsSync(path)) return;
93
+ document["board"] = board;
94
+ mkdirSync(dirname(path), { recursive: true });
95
+ const temporary = `${path}.${randomUUID()}.tmp`;
96
+ try {
97
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, {
98
+ mode: 0o600,
99
+ });
100
+ renameSync(temporary, path);
101
+ } finally {
102
+ rmSync(temporary, { force: true });
103
+ }
104
+ }