@fungi.computer/truffle 0.4.0 → 0.5.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.
@@ -0,0 +1,46 @@
1
+ import type { SessionAttention, SessionSummaryRow } from "@fungi.computer/shiitake/client";
2
+ /**
3
+ * One Agent as the Board reads it from its store, without starting its runtime:
4
+ * its conversations' stored state and the open attention of those that matter.
5
+ */
6
+ export type BoardAgent = Readonly<{
7
+ /** Stable identity of the Agent. */
8
+ agentId: string;
9
+ /** Display name; the id when the Agent has none. */
10
+ name: string;
11
+ /** Set when the Agent's store cannot be read, and why. */
12
+ unreadable?: string;
13
+ /**
14
+ * Whether a live runtime holds the store. A conversation stored as running
15
+ * in a store nobody holds was left behind by a runtime that stopped.
16
+ */
17
+ owned: boolean;
18
+ /** Page one of its conversations, waiting ones first, then running, then settled. */
19
+ sessions: readonly SessionSummaryRow[];
20
+ /** Open attention of the conversations `attentionWanted` names. */
21
+ attention: ReadonlyMap<string, readonly SessionAttention[]>;
22
+ }>;
23
+ /** Waiting conversations whose attention is read per Agent. */
24
+ export declare const maximumWaitingPerAgent = 4;
25
+ /**
26
+ * Which conversations' attention to load: the newest one, whose state is the
27
+ * Agent's lane, and the ones waiting on a person.
28
+ */
29
+ export declare function attentionWanted(sessions: readonly SessionSummaryRow[]): readonly string[];
30
+ /**
31
+ * Whether a live runtime holds this store. A runtime holds an exclusive
32
+ * transaction on the store's `.owner.sqlite` sidecar for as long as its
33
+ * process lives, so a reader that is refused at once has found a live owner,
34
+ * and one that reads the sidecar has found none. Nothing is written.
35
+ */
36
+ export declare function storeIsOwned(databasePath: string): boolean;
37
+ /**
38
+ * One Agent as the Board sees it, read from its store alone: no runtime starts,
39
+ * so reading every Agent every few seconds costs file reads, not Agents.
40
+ * The run state is as the Agent's runtime last wrote it. A store that cannot
41
+ * be read is an unavailable Agent with the reason, never an idle one. An
42
+ * archived Agent is not on the board.
43
+ */
44
+ export declare function readBoardAgent(agentId: string, databasePath: string): Promise<BoardAgent | undefined>;
45
+ /** Every Agent store under a project folder, as the Board reads them. */
46
+ export declare function readBoardAgents(root: string): Promise<readonly BoardAgent[]>;
@@ -0,0 +1,138 @@
1
+ import { readStoredAttention, readStoredSessionStates, } from "@fungi.computer/shiitake/effect";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname } from "node:path";
4
+ import * as Effect from "effect/Effect";
5
+ import { z } from "zod";
6
+ import { logEvent } from "./log-event.js";
7
+ import { listPrivateLocalAgentStores, LocalStoreSchemaError, readPrivateLocalAgentProfile, unreadableStoreReason, } from "./private-local-session-discovery.js";
8
+ import { createPrivateSqliteOwner, openPrivateReadOnlySqliteDatabase, } from "./private-sqlite-owner.js";
9
+ /** Waiting conversations whose attention is read per Agent. */
10
+ export const maximumWaitingPerAgent = 4;
11
+ /**
12
+ * Which conversations' attention to load: the newest one, whose state is the
13
+ * Agent's lane, and the ones waiting on a person.
14
+ */
15
+ export function attentionWanted(sessions) {
16
+ const first = sessions[0];
17
+ return [
18
+ ...new Set([
19
+ ...(first === undefined ? [] : [first.sessionId]),
20
+ ...sessions
21
+ .filter((row) => row.state === "needs_you")
22
+ .slice(0, maximumWaitingPerAgent)
23
+ .map((row) => row.sessionId),
24
+ ]),
25
+ ];
26
+ }
27
+ /** SQLite's "database is locked" result code, from node:sqlite or bun:sqlite. */
28
+ const sqliteBusy = 5;
29
+ /**
30
+ * Whether a live runtime holds this store. A runtime holds an exclusive
31
+ * transaction on the store's `.owner.sqlite` sidecar for as long as its
32
+ * process lives, so a reader that is refused at once has found a live owner,
33
+ * and one that reads the sidecar has found none. Nothing is written.
34
+ */
35
+ export function storeIsOwned(databasePath) {
36
+ const sidecar = `${databasePath}.owner.sqlite`;
37
+ if (!existsSync(sidecar))
38
+ return false;
39
+ let database;
40
+ try {
41
+ database = openPrivateReadOnlySqliteDatabase(sidecar, 0);
42
+ }
43
+ catch (cause) {
44
+ if (busy(cause))
45
+ return true;
46
+ throw cause;
47
+ }
48
+ try {
49
+ database.prepare("SELECT count(*) FROM sqlite_master").all();
50
+ return false;
51
+ }
52
+ catch (cause) {
53
+ if (busy(cause))
54
+ return true;
55
+ throw cause;
56
+ }
57
+ finally {
58
+ database.close();
59
+ }
60
+ }
61
+ /** node:sqlite names the result code `errcode`, bun:sqlite `errno`. */
62
+ const sqliteFailure = z.looseObject({
63
+ errcode: z.number().optional(),
64
+ errno: z.number().optional(),
65
+ });
66
+ function busy(cause) {
67
+ const failure = sqliteFailure.safeParse(cause);
68
+ return (failure.success &&
69
+ (failure.data.errcode ?? failure.data.errno) === sqliteBusy);
70
+ }
71
+ /**
72
+ * One Agent as the Board sees it, read from its store alone: no runtime starts,
73
+ * so reading every Agent every few seconds costs file reads, not Agents.
74
+ * The run state is as the Agent's runtime last wrote it. A store that cannot
75
+ * be read is an unavailable Agent with the reason, never an idle one. An
76
+ * archived Agent is not on the board.
77
+ */
78
+ export async function readBoardAgent(agentId, databasePath) {
79
+ const none = new Map();
80
+ let name;
81
+ let owned;
82
+ try {
83
+ const profile = readPrivateLocalAgentProfile(databasePath);
84
+ if (profile.archived)
85
+ return undefined;
86
+ name = profile.name ?? agentId;
87
+ owned = storeIsOwned(databasePath);
88
+ }
89
+ catch (cause) {
90
+ if (cause instanceof LocalStoreSchemaError)
91
+ return {
92
+ agentId,
93
+ name: agentId,
94
+ sessions: [],
95
+ attention: none,
96
+ owned: false,
97
+ unreadable: unreadableStoreReason(agentId),
98
+ };
99
+ logEvent("board-agent-profile", cause);
100
+ return {
101
+ agentId,
102
+ name: agentId,
103
+ sessions: [],
104
+ attention: none,
105
+ owned: false,
106
+ unreadable: "its store could not be read",
107
+ };
108
+ }
109
+ const database = openPrivateReadOnlySqliteDatabase(databasePath);
110
+ try {
111
+ const owner = createPrivateSqliteOwner(database);
112
+ const sessions = (await Effect.runPromise(readStoredSessionStates(owner))).sessions;
113
+ const attention = new Map();
114
+ for (const sessionId of attentionWanted(sessions))
115
+ attention.set(sessionId, await Effect.runPromise(readStoredAttention(owner, sessionId)));
116
+ return { agentId, name, owned, sessions, attention };
117
+ }
118
+ catch (cause) {
119
+ logEvent("board-agent-store", cause);
120
+ return {
121
+ agentId,
122
+ name,
123
+ owned,
124
+ sessions: [],
125
+ attention: none,
126
+ unreadable: "its conversations could not be read",
127
+ };
128
+ }
129
+ finally {
130
+ database.close();
131
+ }
132
+ }
133
+ /** Every Agent store under a project folder, as the Board reads them. */
134
+ export async function readBoardAgents(root) {
135
+ const stores = await listPrivateLocalAgentStores(root, "all");
136
+ const agents = await Promise.all(stores.map((path) => readBoardAgent(basename(dirname(path)), path)));
137
+ return agents.flatMap((agent) => (agent === undefined ? [] : [agent]));
138
+ }
@@ -0,0 +1,129 @@
1
+ import * as Result from "effect/Result";
2
+ import * as Schema from "effect/Schema";
3
+ /** What happened to a piece of work: it landed, shipped out, or is on its way. */
4
+ export declare const ShipKind: Schema.Literals<readonly ["landed", "released", "queued", "gating", "dropped"]>;
5
+ /** Display kind of one shipped item. */
6
+ export type ShipKind = typeof ShipKind.Type;
7
+ /** One thing a shipping source saw since local midnight. */
8
+ export declare const ShipItem: Schema.Struct<{
9
+ /** Stable within its source. */
10
+ readonly id: Schema.String;
11
+ /** What happened to it. */
12
+ readonly kind: Schema.Literals<readonly ["landed", "released", "queued", "gating", "dropped"]>;
13
+ /** A one-line title, such as a commit subject or a released version. */
14
+ readonly title: Schema.String;
15
+ /** The branch, tag or package it belongs to. */
16
+ readonly ref: Schema.optionalKey<Schema.String>;
17
+ /** When it happened. */
18
+ readonly at: Schema.Int;
19
+ /** A short note the source adds. */
20
+ readonly detail: Schema.optionalKey<Schema.String>;
21
+ }>;
22
+ /** Decoded shipped item. */
23
+ export type ShipItem = typeof ShipItem.Type;
24
+ /** What one read of a shipping source found; a throw is a failed source instead. */
25
+ export type ShippingRead = Readonly<{
26
+ /** `held` is paused on purpose, `stopped` is not running; neither is an error. */
27
+ state: "ok" | "held" | "stopped";
28
+ /** Why it is held or stopped, in a few words. */
29
+ detail?: string;
30
+ /** What the source saw since `sinceMs`, newest first. */
31
+ items: readonly ShipItem[];
32
+ }>;
33
+ /**
34
+ * Something that ships work: a branch, a package registry, a merge train. The
35
+ * Board reads it on its own cadence and never trusts it to say "none" when it
36
+ * could not look.
37
+ */
38
+ export type ShippingSource = Readonly<{
39
+ /** Stable identity of the source. */
40
+ id: string;
41
+ /** What the person calls the source. */
42
+ label: string;
43
+ /** How often the source is read again. */
44
+ refreshMs: number;
45
+ /** Read what shipped at or after `sinceMs` (local midnight); throw when it cannot be read. */
46
+ read: (sinceMs: number, signal: AbortSignal) => Promise<ShippingRead>;
47
+ }>;
48
+ /** The sources the Board reads, resolved afresh each cycle so settings edits apply. */
49
+ export type ShippingSources = (signal: AbortSignal) => Promise<readonly ShippingSource[]>;
50
+ /** The most shipping sources one board reads. */
51
+ export declare const maximumShippingSources = 6;
52
+ /** One configured shipping source; an unknown kind or key is refused. */
53
+ export declare const ShippingSourceConfig: Schema.Union<readonly [Schema.Struct<{
54
+ /** Reads a Git history. */
55
+ readonly kind: Schema.Literal<"git">;
56
+ /** The branch, tag or `HEAD` whose first-parent history is read. */
57
+ readonly ref: Schema.String;
58
+ /** Whether those commits landed work or released it. */
59
+ readonly shows: Schema.Literals<readonly ["landed", "released"]>;
60
+ }>, Schema.Struct<{
61
+ /** Reads a package registry. */
62
+ readonly kind: Schema.Literal<"npm">;
63
+ /** Package names, as `npm view` takes them. */
64
+ readonly packages: Schema.$Array<Schema.String>;
65
+ }>]>;
66
+ /** Decoded configured shipping source. */
67
+ export type ShippingSourceConfig = typeof ShippingSourceConfig.Type;
68
+ /** The `board` key of a `.fungi/settings.json`; excess keys are refused. */
69
+ export declare const BoardSettings: Schema.Struct<{
70
+ /** The sources the Board reads for what shipped today. */
71
+ readonly shipping: Schema.optionalKey<Schema.$Array<Schema.Union<readonly [Schema.Struct<{
72
+ /** Reads a Git history. */
73
+ readonly kind: Schema.Literal<"git">;
74
+ /** The branch, tag or `HEAD` whose first-parent history is read. */
75
+ readonly ref: Schema.String;
76
+ /** Whether those commits landed work or released it. */
77
+ readonly shows: Schema.Literals<readonly ["landed", "released"]>;
78
+ }>, Schema.Struct<{
79
+ /** Reads a package registry. */
80
+ readonly kind: Schema.Literal<"npm">;
81
+ /** Package names, as `npm view` takes them. */
82
+ readonly packages: Schema.$Array<Schema.String>;
83
+ }>]>>>;
84
+ }>;
85
+ /** The sources a settings `board` value names, or each refusal with its settings path. */
86
+ export declare function decodeBoardSettings(board: unknown): Result.Result<readonly ShippingSourceConfig[], readonly string[]>;
87
+ /** Run a program with these arguments and return what it wrote to stdout. */
88
+ export type ShippingExec = (file: string, args: readonly string[], options: Readonly<{
89
+ cwd: string;
90
+ signal: AbortSignal;
91
+ }>) => Promise<string>;
92
+ /**
93
+ * The tool's ordinary interface: its own argv, no shell, a bounded environment.
94
+ * A program that is missing or refuses is an error with a short reason.
95
+ */
96
+ export declare const execShipping: ShippingExec;
97
+ /**
98
+ * Commits first-parent on `ref` since local midnight, from `git log`. A merge
99
+ * shows the subject of the tip it merged, and any other commit its own, so a
100
+ * merge queue's "Merge branch ..." lines read as the work that landed.
101
+ */
102
+ export declare function gitSource(config: Extract<ShippingSourceConfig, {
103
+ kind: "git";
104
+ }>, options: Readonly<{
105
+ cwd: string;
106
+ exec: ShippingExec;
107
+ label?: string;
108
+ }>): ShippingSource;
109
+ /** Versions of each package published since local midnight, from `npm view <package> time --json`. */
110
+ export declare function npmSource(config: Extract<ShippingSourceConfig, {
111
+ kind: "npm";
112
+ }>, options: Readonly<{
113
+ cwd: string;
114
+ exec: ShippingExec;
115
+ }>): ShippingSource;
116
+ /**
117
+ * The Board's shipping sources for this folder: `board.shipping` in the
118
+ * folder's `.fungi/settings.json`, then the person's, read afresh each cycle so
119
+ * an edit applies. A refused file is one failed source line naming its
120
+ * path, never a silent default. With nothing configured, a Git work tree
121
+ * shows its current branch as landed work, and a folder that is not one shows
122
+ * no shipping at all.
123
+ */
124
+ export declare function createLocalShippingSources(options: Readonly<{
125
+ cwd: string;
126
+ home: string;
127
+ exec?: ShippingExec;
128
+ now?: () => number;
129
+ }>): ShippingSources;
@@ -0,0 +1,325 @@
1
+ import { execFile } from "node:child_process";
2
+ import { open } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import * as Result from "effect/Result";
5
+ import * as Schema from "effect/Schema";
6
+ import * as SchemaIssue from "effect/SchemaIssue";
7
+ /** What happened to a piece of work: it landed, shipped out, or is on its way. */
8
+ export const ShipKind = Schema.Literals([
9
+ "landed",
10
+ "released",
11
+ "queued",
12
+ "gating",
13
+ "dropped",
14
+ ]);
15
+ const text = Schema.String.check(Schema.isMaxLength(200));
16
+ /** One thing a shipping source saw since local midnight. */
17
+ export const ShipItem = Schema.Struct({
18
+ /** Stable within its source. */
19
+ id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
20
+ /** What happened to it. */
21
+ kind: ShipKind,
22
+ /** A one-line title, such as a commit subject or a released version. */
23
+ title: text,
24
+ /** The branch, tag or package it belongs to. */
25
+ ref: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256))),
26
+ /** When it happened. */
27
+ at: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
28
+ /** A short note the source adds. */
29
+ detail: Schema.optionalKey(text),
30
+ });
31
+ /** The most shipping sources one board reads. */
32
+ export const maximumShippingSources = 6;
33
+ const reference = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
34
+ /** Commits first-parent on `ref` since midnight; a protected branch's merges are its landings. */
35
+ const gitSourceConfig = Schema.Struct({
36
+ /** Reads a Git history. */
37
+ kind: Schema.Literal("git"),
38
+ /** The branch, tag or `HEAD` whose first-parent history is read. */
39
+ ref: reference,
40
+ /** Whether those commits landed work or released it. */
41
+ shows: Schema.Literals(["landed", "released"]),
42
+ });
43
+ /** Versions of these packages published today. */
44
+ const npmSourceConfig = Schema.Struct({
45
+ /** Reads a package registry. */
46
+ kind: Schema.Literal("npm"),
47
+ /** Package names, as `npm view` takes them. */
48
+ packages: Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(214))).check(Schema.isMinLength(1), Schema.isMaxLength(8)),
49
+ });
50
+ /** One configured shipping source; an unknown kind or key is refused. */
51
+ export const ShippingSourceConfig = Schema.Union([
52
+ gitSourceConfig,
53
+ npmSourceConfig,
54
+ ]);
55
+ /** The `board` key of a `.fungi/settings.json`; excess keys are refused. */
56
+ export const BoardSettings = Schema.Struct({
57
+ /** The sources the Board reads for what shipped today. */
58
+ shipping: Schema.optionalKey(Schema.Array(ShippingSourceConfig).check(Schema.isMaxLength(maximumShippingSources))),
59
+ });
60
+ const isKey = Schema.is(Schema.Union([Schema.String, Schema.Finite]));
61
+ /** The sources a settings `board` value names, or each refusal with its settings path. */
62
+ export function decodeBoardSettings(board) {
63
+ const decoded = Schema.decodeUnknownResult(BoardSettings, {
64
+ onExcessProperty: "error",
65
+ errors: "all",
66
+ })(board);
67
+ if (Result.isSuccess(decoded))
68
+ return Result.succeed(decoded.success.shipping ?? []);
69
+ const format = SchemaIssue.makeFormatterStandardSchemaV1();
70
+ return Result.fail(format(decoded.failure.issue).issues.map(({ path, message }) => (path ?? []).reduce((where, part) => {
71
+ // Settings are JSON, so every path segment is a name or an index.
72
+ const key = isKey(part) ? String(part) : "?";
73
+ return /^\d+$/u.test(key) ? `${where}[${key}]` : `${where}.${key}`;
74
+ }, "board") + `: ${message}`));
75
+ }
76
+ /** A settings file larger than this is not read. */
77
+ const maximumSettingsBytes = 64 * 1024;
78
+ /** Git history is read at most this many commits deep; the board lists far fewer. */
79
+ const maximumCommits = 200;
80
+ const gitRefreshMs = 60_000;
81
+ const npmRefreshMs = 5 * 60_000;
82
+ /** How long the answer to "is this a work tree, and on which branch" is kept. */
83
+ const defaultBranchMs = 60_000;
84
+ /** The first line a program said, so a refusal reads as a reason and not as a command line. */
85
+ function reason(file, stderr, cause) {
86
+ const line = stderr.split("\n").find((text) => text.trim() !== "");
87
+ if ("code" in cause && cause.code === "ENOENT")
88
+ return `${file} is not installed`;
89
+ return `${file} failed${line === undefined ? "" : `: ${line.trim()}`}`;
90
+ }
91
+ /**
92
+ * The tool's ordinary interface: its own argv, no shell, a bounded environment.
93
+ * A program that is missing or refuses is an error with a short reason.
94
+ */
95
+ export const execShipping = (file, args, { cwd, signal }) => new Promise((resolve, reject) => {
96
+ execFile(file, [...args], {
97
+ cwd,
98
+ signal,
99
+ maxBuffer: 4 * 1024 * 1024,
100
+ env: {
101
+ ...process.env,
102
+ GIT_TERMINAL_PROMPT: "0",
103
+ GIT_OPTIONAL_LOCKS: "0",
104
+ LC_ALL: "C",
105
+ npm_config_update_notifier: "false",
106
+ npm_config_fund: "false",
107
+ npm_config_audit: "false",
108
+ },
109
+ }, (error, stdout, stderr) => {
110
+ if (error === null)
111
+ resolve(stdout);
112
+ else
113
+ reject(new Error(reason(file, stderr, error)));
114
+ });
115
+ });
116
+ const decodeItems = Schema.decodeUnknownSync(Schema.Array(ShipItem));
117
+ /**
118
+ * Commits first-parent on `ref` since local midnight, from `git log`. A merge
119
+ * shows the subject of the tip it merged, and any other commit its own, so a
120
+ * merge queue's "Merge branch ..." lines read as the work that landed.
121
+ */
122
+ export function gitSource(config, options) {
123
+ const { ref, shows } = config;
124
+ return {
125
+ id: `git:${ref}:${shows}`,
126
+ label: options.label ?? ref,
127
+ refreshMs: gitRefreshMs,
128
+ read: async (sinceMs, signal) => {
129
+ const run = (args) => options.exec("git", args, { cwd: options.cwd, signal });
130
+ const log = await run([
131
+ "log",
132
+ "--first-parent",
133
+ `--since=${new Date(sinceMs).toISOString()}`,
134
+ `--max-count=${String(maximumCommits)}`,
135
+ "--format=%H%x1f%ct%x1f%s%x1f%P%x1e",
136
+ ref,
137
+ "--",
138
+ ]);
139
+ const commits = records(log, 4).map(([hash, time, subject, parents]) => ({
140
+ hash: hash ?? "",
141
+ time: Number(time),
142
+ subject: subject ?? "",
143
+ merged: (parents ?? "").split(" ").filter((parent) => parent !== "")[1],
144
+ }));
145
+ const tips = [
146
+ ...new Set(commits.flatMap(({ merged }) => merged === undefined ? [] : [merged])),
147
+ ];
148
+ const tipSubjects = new Map();
149
+ if (tips.length > 0) {
150
+ const shown = await run([
151
+ "log",
152
+ "--no-walk=unsorted",
153
+ "--format=%H%x1f%s%x1e",
154
+ ...tips,
155
+ "--",
156
+ ]);
157
+ for (const [hash, subject] of records(shown, 2))
158
+ if (hash !== undefined)
159
+ tipSubjects.set(hash, subject ?? "");
160
+ }
161
+ const items = decodeItems(commits.map(({ hash, time, subject, merged }) => ({
162
+ id: hash.slice(0, 12),
163
+ kind: shows,
164
+ title: (merged === undefined
165
+ ? subject
166
+ : (tipSubjects.get(merged) ?? subject)).slice(0, 200),
167
+ ref,
168
+ at: time * 1000,
169
+ })));
170
+ return { state: "ok", items: items.filter((item) => item.at >= sinceMs) };
171
+ },
172
+ };
173
+ }
174
+ /** Split a program's `%x1e`-terminated, `%x1f`-separated records into fields. */
175
+ function records(output, fields) {
176
+ return output
177
+ .split("\x1e")
178
+ .map((record) => record.replace(/^\n+/u, ""))
179
+ .filter((record) => record !== "")
180
+ .map((record) => record.split("\x1f", fields));
181
+ }
182
+ const publishTimes = Schema.Record(Schema.String, Schema.String);
183
+ const decodePublishTimes = Schema.decodeUnknownSync(publishTimes);
184
+ /** Keys of `npm view time` that are not versions. */
185
+ const notVersions = new Set(["created", "modified"]);
186
+ /** Versions of each package published since local midnight, from `npm view <package> time --json`. */
187
+ export function npmSource(config, options) {
188
+ const { packages } = config;
189
+ return {
190
+ id: `npm:${packages.join(",")}`,
191
+ label: `npm: ${packages.join(", ")}`,
192
+ refreshMs: npmRefreshMs,
193
+ read: async (sinceMs, signal) => {
194
+ const found = [];
195
+ for (const name of packages) {
196
+ const times = decodePublishTimes(JSON.parse(await options.exec("npm", ["view", name, "time", "--json"], {
197
+ cwd: options.cwd,
198
+ signal,
199
+ })));
200
+ for (const [version, published] of Object.entries(times)) {
201
+ const at = Date.parse(published);
202
+ if (!notVersions.has(version) && at >= sinceMs)
203
+ found.push({
204
+ id: `${name}@${version}`,
205
+ kind: "released",
206
+ title: `${name} ${version}`,
207
+ ref: name,
208
+ at,
209
+ });
210
+ }
211
+ }
212
+ return {
213
+ state: "ok",
214
+ items: decodeItems(found.sort((left, right) => right.at - left.at)),
215
+ };
216
+ },
217
+ };
218
+ }
219
+ /** A source that only says why the settings could not be used. */
220
+ function settingsProblem(file, message) {
221
+ return {
222
+ id: `settings:${file}`,
223
+ label: "Board settings",
224
+ refreshMs: gitRefreshMs,
225
+ read: () => Promise.reject(new Error(`${file}: ${message}`)),
226
+ };
227
+ }
228
+ /** Read one settings file's `board` key; a missing file or key says nothing. */
229
+ async function readSettings(path) {
230
+ let text;
231
+ try {
232
+ const handle = await open(path, "r");
233
+ try {
234
+ const bytes = Buffer.alloc(maximumSettingsBytes + 1);
235
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
236
+ if (bytesRead > maximumSettingsBytes)
237
+ return {
238
+ kind: "refused",
239
+ problems: ["the file is larger than 64 KiB"],
240
+ };
241
+ text = bytes.subarray(0, bytesRead).toString("utf8");
242
+ }
243
+ finally {
244
+ await handle.close();
245
+ }
246
+ }
247
+ catch (cause) {
248
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
249
+ return { kind: "none" };
250
+ return {
251
+ kind: "refused",
252
+ problems: [cause instanceof Error ? cause.message : "could not be read"],
253
+ };
254
+ }
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(text);
258
+ }
259
+ catch {
260
+ return { kind: "refused", problems: ["settings are not valid JSON"] };
261
+ }
262
+ const board = Schema.decodeUnknownOption(Schema.Struct({ board: Schema.optionalKey(Schema.Unknown) }))(parsed);
263
+ if (board._tag === "None" || board.value.board === undefined)
264
+ return { kind: "none" };
265
+ const decoded = decodeBoardSettings(board.value.board);
266
+ return decoded._tag === "Success"
267
+ ? { kind: "sources", sources: decoded.success }
268
+ : { kind: "refused", problems: decoded.failure };
269
+ }
270
+ /**
271
+ * The Board's shipping sources for this folder: `board.shipping` in the
272
+ * folder's `.fungi/settings.json`, then the person's, read afresh each cycle so
273
+ * an edit applies. A refused file is one failed source line naming its
274
+ * path, never a silent default. With nothing configured, a Git work tree
275
+ * shows its current branch as landed work, and a folder that is not one shows
276
+ * no shipping at all.
277
+ */
278
+ export function createLocalShippingSources(options) {
279
+ const exec = options.exec ?? execShipping;
280
+ const now = options.now ?? Date.now;
281
+ let branch;
282
+ /** The current branch when this folder is a Git work tree; asked at most once a minute. */
283
+ async function currentBranch(signal) {
284
+ if (branch !== undefined && now() - branch.at < defaultBranchMs)
285
+ return branch.name;
286
+ let name;
287
+ try {
288
+ name = (await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
289
+ cwd: options.cwd,
290
+ signal,
291
+ })).trim();
292
+ }
293
+ catch {
294
+ // Not a work tree, or no Git: there is nothing to default to.
295
+ name = null;
296
+ }
297
+ branch = { at: now(), name: name === "" ? null : name };
298
+ return branch.name;
299
+ }
300
+ return async (signal) => {
301
+ const files = [
302
+ join(options.cwd, ".fungi/settings.json"),
303
+ join(options.home, ".fungi/settings.json"),
304
+ ];
305
+ const configs = [];
306
+ const problems = [];
307
+ for (const file of files) {
308
+ const read = await readSettings(file);
309
+ if (read.kind === "sources")
310
+ configs.push(...read.sources);
311
+ else if (read.kind === "refused")
312
+ problems.push(settingsProblem(file, read.problems.join("; ")));
313
+ }
314
+ const context = { cwd: options.cwd, exec };
315
+ const sources = configs.map((config) => config.kind === "git"
316
+ ? gitSource(config, context)
317
+ : npmSource(config, context));
318
+ if (sources.length === 0 && problems.length === 0) {
319
+ const name = await currentBranch(signal);
320
+ if (name !== null)
321
+ sources.push(gitSource({ kind: "git", ref: "HEAD", shows: "landed" }, { ...context, label: name }));
322
+ }
323
+ return [...sources, ...problems];
324
+ };
325
+ }
@@ -41,6 +41,8 @@ export type LocalExtensionTerminal = Readonly<{
41
41
  terminalId: string;
42
42
  name: string;
43
43
  description: string;
44
+ /** A shell key that opens this program beside the focused pane. */
45
+ binding?: string;
44
46
  /** Absolute executable followed by its arguments. */
45
47
  command: readonly string[];
46
48
  cwd: string;