@huseynsnmz/pi-suite 0.1.0 → 0.2.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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -1
- package/package.json +6 -7
- package/packages/board/README.md +1 -1
- package/packages/board/package.json +1 -3
- package/packages/board/src/core/rows.mjs +21 -5
- package/packages/board/src/ui/dashboard.ts +36 -20
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +20 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +141 -0
- package/packages/frame/README.md +2 -2
- package/packages/frame/package.json +1 -4
- package/packages/frame/src/data/github.ts +1 -1
- package/packages/frame/src/data/mode.ts +28 -2
- package/packages/frame/src/data/snapshot.ts +1 -1
- package/packages/frame/src/data/start-notes.ts +1 -1
- package/packages/frame/src/data/totals.ts +2 -2
- package/packages/frame/src/data/visits.ts +1 -1
- package/packages/frame/src/index.ts +25 -3
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +55 -2
- package/packages/frame/src/render/fit.ts +1 -1
- package/packages/frame/src/render/format.ts +1 -1
- package/packages/frame/src/render/segments.ts +1 -1
- package/packages/frame/src/render/streaming.ts +1 -1
- package/packages/frame/src/render/tools.ts +1 -1
- package/packages/frame/src/settings.ts +1 -1
- package/packages/frame/src/ui/editor.ts +1 -1
- package/packages/frame/src/ui/effort.ts +1 -1
- package/packages/frame/src/ui/header.ts +1 -1
- package/packages/frame/src/ui/outputs.ts +1 -1
- package/packages/frame/src/ui/pi-entries.ts +28 -6
- package/packages/frame/src/ui/previews.ts +1 -1
- package/packages/frame/src/ui/queue.ts +1 -1
- package/packages/frame/src/ui/todos.ts +1 -1
- package/packages/frame/src/ui/tool-frame.ts +1 -1
- package/packages/frame/src/view/state.ts +1 -1
- package/packages/frame/src/view/transcript.ts +1 -1
- package/packages/frame/src/view/view.ts +1 -1
- package/packages/memory/README.md +1 -1
- package/packages/modes/README.md +1 -1
- package/packages/router/README.md +1 -1
- package/packages/web/README.md +1 -1
- package/packages/web/package.json +1 -3
- package/packages/web/src/extract.ts +2 -2
- package/packages/web/src/markdown.ts +156 -0
- package/src/index.ts +1 -1
- package/src/settings.ts +1 -0
|
@@ -4,7 +4,7 @@ import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
|
4
4
|
const MODE_STATUS = "pi-modes";
|
|
5
5
|
const REPORTED = /^mode: (normal|auto|plan|read-only|yolo)$/;
|
|
6
6
|
|
|
7
|
-
type Mode = "normal" | "auto" | "plan" | "read-only" | "yolo";
|
|
7
|
+
export type Mode = "normal" | "auto" | "plan" | "read-only" | "yolo";
|
|
8
8
|
|
|
9
9
|
/** The same colors pi-modes gives the word. In `normal`, the frame stays pi's own. */
|
|
10
10
|
const FRAME_COLORS = { auto: "warning", plan: "accent", "read-only": "success", yolo: "error" } as const;
|
|
@@ -15,9 +15,35 @@ export function modeOf(statuses: ReadonlyMap<string, string>): Mode | undefined
|
|
|
15
15
|
return REPORTED.exec(stripTerminalSequences(status).trim())?.[1] as Mode | undefined;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** A message's frame stays pi's own in `normal` too. */
|
|
19
|
+
export function modeColor(mode: Mode): (typeof FRAME_COLORS)[keyof typeof FRAME_COLORS] | undefined {
|
|
20
|
+
return mode === "normal" ? undefined : FRAME_COLORS[mode];
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
export function frameColorOf(
|
|
19
24
|
statuses: ReadonlyMap<string, string>,
|
|
20
25
|
): (typeof FRAME_COLORS)[keyof typeof FRAME_COLORS] | undefined {
|
|
21
26
|
const mode = modeOf(statuses);
|
|
22
|
-
return mode === undefined
|
|
27
|
+
return mode === undefined ? undefined : modeColor(mode);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Saved with the session, so a resumed conversation still shows which mode each message went out in. */
|
|
31
|
+
export const SENT_IN_ENTRY = "pi-frame-sent-in";
|
|
32
|
+
|
|
33
|
+
/** The session entry of each message sent in a mode, joined on the message's timestamp, since a live message has no entry yet. */
|
|
34
|
+
export function sentInFromEntries(
|
|
35
|
+
entries: readonly { type: string; id?: string; customType?: string; data?: unknown; message?: { role?: string; timestamp?: number } }[],
|
|
36
|
+
): Map<string, Mode> {
|
|
37
|
+
const byTime = new Map<number, Mode>();
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.type !== "custom" || entry.customType !== SENT_IN_ENTRY) continue;
|
|
40
|
+
const { at, mode } = (entry.data ?? {}) as { at?: unknown; mode?: unknown };
|
|
41
|
+
if (typeof at === "number" && typeof mode === "string" && REPORTED.test(`mode: ${mode}`)) byTime.set(at, mode as Mode);
|
|
42
|
+
}
|
|
43
|
+
const sent = new Map<string, Mode>();
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const mode = entry.type === "message" && entry.message?.role === "user" ? byTime.get(entry.message.timestamp ?? -1) : undefined;
|
|
46
|
+
if (mode !== undefined && entry.id !== undefined) sent.set(entry.id, mode);
|
|
47
|
+
}
|
|
48
|
+
return sent;
|
|
23
49
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
|
|
3
|
-
import type { AccountState, Board, PeerMark, RoomMark, WritingMark } from "
|
|
3
|
+
import type { AccountState, Board, PeerMark, RoomMark, WritingMark } from "../kit/index.ts";
|
|
4
4
|
import { VERSION, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
import { plainText } from "../render/safe.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Under the folder and the model, only what changed or waits on you since the last session here.
|
|
2
|
-
import { formatClock, GLYPH } from "
|
|
2
|
+
import { formatClock, GLYPH } from "../kit/index.ts";
|
|
3
3
|
|
|
4
4
|
import type { GithubState } from "./github.ts";
|
|
5
5
|
import type { Snapshot } from "./snapshot.ts";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type UsageLike } from "
|
|
1
|
+
import { type UsageLike } from "../kit/index.ts";
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
4
|
emptyTotals,
|
|
@@ -7,7 +7,7 @@ export {
|
|
|
7
7
|
totalsOf,
|
|
8
8
|
type Totals,
|
|
9
9
|
usageTotals,
|
|
10
|
-
} from "
|
|
10
|
+
} from "../kit/index.ts";
|
|
11
11
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
13
|
export interface RequestRecord {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
|
|
5
|
-
import { isObject } from "
|
|
5
|
+
import { isObject } from "../kit/index.ts";
|
|
6
6
|
|
|
7
7
|
import type { LastVisit } from "./start-notes.ts";
|
|
8
8
|
|
|
@@ -17,18 +17,18 @@ import {
|
|
|
17
17
|
type SessionSource,
|
|
18
18
|
skillNames,
|
|
19
19
|
sourceFor,
|
|
20
|
-
} from "
|
|
20
|
+
} from "./kit/index.ts";
|
|
21
21
|
import type {
|
|
22
22
|
ExtensionAPI,
|
|
23
23
|
ExtensionContext,
|
|
24
24
|
ReadonlyFooterDataProvider,
|
|
25
25
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { Key, type KeyId, type TUI } from "@earendil-works/pi-tui";
|
|
28
28
|
|
|
29
29
|
import { type GitState, readGit } from "./data/git.ts";
|
|
30
30
|
import { cmdAsKey, SETTINGS_SHORTCUT, watchCmdKey } from "./render/keys.ts";
|
|
31
|
-
import { frameColorOf } from "./data/mode.ts";
|
|
31
|
+
import { frameColorOf, type Mode, modeOf, SENT_IN_ENTRY, sentInFromEntries } from "./data/mode.ts";
|
|
32
32
|
import { type GithubState, readGithub } from "./data/github.ts";
|
|
33
33
|
import { emptySnapshot, type LiveInputs, type Snapshot, SnapshotReader } from "./data/snapshot.ts";
|
|
34
34
|
import type { LastVisit } from "./data/start-notes.ts";
|
|
@@ -126,6 +126,8 @@ export const frameFeature = defineFeature({
|
|
|
126
126
|
registerToolFrames(scope);
|
|
127
127
|
registerEffort(scope);
|
|
128
128
|
registerTodos(scope, () => glyphs().set);
|
|
129
|
+
const sentLive = new Map<string, Mode>();
|
|
130
|
+
let sentSaved = { length: -1, modes: new Map<string, Mode>() };
|
|
129
131
|
framePiEntries(() => {
|
|
130
132
|
if (live.ctx === undefined) return undefined;
|
|
131
133
|
return {
|
|
@@ -133,6 +135,12 @@ export const frameFeature = defineFeature({
|
|
|
133
135
|
set: glyphs().set,
|
|
134
136
|
you: userFrame.get(scope),
|
|
135
137
|
from: (text: string) => PROMPT_FROM.current(scope, text)?.name,
|
|
138
|
+
sentIn: (id: string | undefined, text: string) => {
|
|
139
|
+
if (id === undefined) return sentLive.get(text);
|
|
140
|
+
const entries = live.ctx?.sessionManager.getEntries() ?? [];
|
|
141
|
+
if (entries.length !== sentSaved.length) sentSaved = { length: entries.length, modes: sentInFromEntries(entries) };
|
|
142
|
+
return sentSaved.modes.get(id);
|
|
143
|
+
},
|
|
136
144
|
};
|
|
137
145
|
});
|
|
138
146
|
showTimings((message, width) => {
|
|
@@ -215,6 +223,8 @@ export const frameFeature = defineFeature({
|
|
|
215
223
|
live.ctx = ctx;
|
|
216
224
|
telemetry.reset();
|
|
217
225
|
telemetry.restore(ctx.sessionManager.getEntries());
|
|
226
|
+
sentLive.clear();
|
|
227
|
+
sentSaved = { length: -1, modes: new Map() };
|
|
218
228
|
void probeGit(live);
|
|
219
229
|
if (ctx.mode !== "tui") return;
|
|
220
230
|
|
|
@@ -242,6 +252,18 @@ export const frameFeature = defineFeature({
|
|
|
242
252
|
showHeader(ctx);
|
|
243
253
|
});
|
|
244
254
|
|
|
255
|
+
scope.on("message_start", (event) => {
|
|
256
|
+
if (event.message.role !== "user") return;
|
|
257
|
+
const mode = modeOf(live.footerData?.getExtensionStatuses() ?? new Map());
|
|
258
|
+
if (mode === undefined) return;
|
|
259
|
+
const { content, timestamp } = event.message;
|
|
260
|
+
const text = typeof content === "string" ? content : content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
|
|
261
|
+
// Pi draws a skill's message as only what you typed after it.
|
|
262
|
+
sentLive.set(parseSkillBlock(text)?.userMessage ?? text, mode);
|
|
263
|
+
// Normal draws as a plain message, so a resumed session needs nothing saved for it.
|
|
264
|
+
if (mode !== "normal") scope.appendEntry(SENT_IN_ENTRY, { at: timestamp, mode });
|
|
265
|
+
});
|
|
266
|
+
|
|
245
267
|
scope.onShutdown(() => {
|
|
246
268
|
stopCmdKeys?.();
|
|
247
269
|
stopCmdKeys = undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 adeildo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// In the harness every feature runs inside one extension, so pi reports every failure under the
|
|
2
|
+
// same name. These put the app and the feature in front of it instead.
|
|
3
|
+
|
|
4
|
+
export function describe(error: unknown): string {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A sync function stays sync, because pi treats some handler results differently when they are
|
|
9
|
+
// promises.
|
|
10
|
+
export function attributed<A extends unknown[], R>(
|
|
11
|
+
label: string,
|
|
12
|
+
run: (...args: A) => R,
|
|
13
|
+
): (...args: A) => R {
|
|
14
|
+
return (...args: A): R => {
|
|
15
|
+
let result: R;
|
|
16
|
+
try {
|
|
17
|
+
result = run(...args);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw relabel(label, error);
|
|
20
|
+
}
|
|
21
|
+
if (!(result instanceof Promise)) return result;
|
|
22
|
+
return result.catch((error: unknown) => {
|
|
23
|
+
throw relabel(label, error);
|
|
24
|
+
}) as R;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function relabel(label: string, error: unknown): Error {
|
|
29
|
+
return new Error(`${label}: ${describe(error)}`, { cause: error });
|
|
30
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `createApp(pi, ...).use(feature).build()`. Registration happens only in the factory, session work
|
|
2
|
+
// from `session_start`, cleanup once per session, and a feature that fails to set up is a warning.
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { CHANGED } from "../contracts/screen.ts";
|
|
6
|
+
import { boolean, isObject } from "../decode.ts";
|
|
7
|
+
import { settingsScreen } from "../screen/feature.ts";
|
|
8
|
+
import { globalSettingsPath, projectSettingsPath } from "../settings/files.ts";
|
|
9
|
+
import { type Setting, type SettingsScope, setting } from "../settings/setting.ts";
|
|
10
|
+
import { SettingsStore } from "../settings/store.ts";
|
|
11
|
+
import { describe } from "./attribution.ts";
|
|
12
|
+
import { answerClaims, ownerOf } from "./claim.ts";
|
|
13
|
+
import type { Feature } from "./feature.ts";
|
|
14
|
+
import { serveScreen } from "./host.ts";
|
|
15
|
+
import { type AppState, createScope, type Hook } from "./scope.ts";
|
|
16
|
+
|
|
17
|
+
export interface App extends SettingsScope {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly settings: SettingsStore;
|
|
20
|
+
has(featureId: string): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AppOptions {
|
|
24
|
+
name: string;
|
|
25
|
+
settingsPath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AppBuilder {
|
|
29
|
+
use(feature: Feature): AppBuilder;
|
|
30
|
+
build(): App;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createApp(pi: ExtensionAPI, options: AppOptions): AppBuilder {
|
|
34
|
+
const features: Feature[] = [];
|
|
35
|
+
const builder: AppBuilder = {
|
|
36
|
+
use(feature) {
|
|
37
|
+
features.push(feature);
|
|
38
|
+
return builder;
|
|
39
|
+
},
|
|
40
|
+
build: () => mount(pi, options, features),
|
|
41
|
+
};
|
|
42
|
+
return builder;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function enabledSetting(feature: Feature): Setting<boolean> {
|
|
46
|
+
return setting({
|
|
47
|
+
id: `features.${feature.id}.enabled`,
|
|
48
|
+
default: true,
|
|
49
|
+
decoder: boolean,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature[]): App {
|
|
54
|
+
const settings = new SettingsStore(options.settingsPath ?? globalSettingsPath());
|
|
55
|
+
const mounted = new Set<string>();
|
|
56
|
+
const queued: string[] = [];
|
|
57
|
+
let session: ExtensionContext | undefined;
|
|
58
|
+
let live = true;
|
|
59
|
+
|
|
60
|
+
function deliver(line: string): void {
|
|
61
|
+
if (session === undefined) queued.push(line);
|
|
62
|
+
else if (session.hasUI) session.ui.notify(line, "warning");
|
|
63
|
+
else console.error(line);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const state: AppState = {
|
|
67
|
+
name: options.name,
|
|
68
|
+
pi,
|
|
69
|
+
settings,
|
|
70
|
+
mounted,
|
|
71
|
+
commands: new Map(),
|
|
72
|
+
starts: [],
|
|
73
|
+
shutdowns: [],
|
|
74
|
+
screen: new Map(),
|
|
75
|
+
screenGroups: new Map(),
|
|
76
|
+
live: () => live,
|
|
77
|
+
stop: () => {
|
|
78
|
+
live = false;
|
|
79
|
+
},
|
|
80
|
+
report: (source, message) => deliver(`${options.name}: ${source}: ${message}`),
|
|
81
|
+
};
|
|
82
|
+
const app: App = { name: options.name, settings, has: (id) => mounted.has(id) };
|
|
83
|
+
const running: Feature[] = [];
|
|
84
|
+
|
|
85
|
+
answerClaims(pi, options.name, mounted, () => live);
|
|
86
|
+
serveScreen({
|
|
87
|
+
pi,
|
|
88
|
+
settings,
|
|
89
|
+
features: () => running,
|
|
90
|
+
screen: state.screen,
|
|
91
|
+
screenGroups: state.screenGroups,
|
|
92
|
+
session: () => session,
|
|
93
|
+
isLive: () => live,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Every app writes the same files, so a write by one is read again by the others.
|
|
97
|
+
const store = crypto.randomUUID();
|
|
98
|
+
settings.onWrite(() => pi.events.emit(CHANGED, { source: store }));
|
|
99
|
+
pi.events.on(CHANGED, (data) => {
|
|
100
|
+
if (!live || !isObject(data) || data.source === store) return;
|
|
101
|
+
for (const line of settings.load(settings.projectPath)) state.report("settings", line);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Registered first, so these run ahead of the features' own handlers.
|
|
105
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
106
|
+
live = true;
|
|
107
|
+
session = ctx;
|
|
108
|
+
for (const line of queued.splice(0)) deliver(line);
|
|
109
|
+
const project = ctx.isProjectTrusted() ? projectSettingsPath(ctx.cwd) : undefined;
|
|
110
|
+
for (const line of settings.load(project)) state.report("settings", line);
|
|
111
|
+
await runInOrder(
|
|
112
|
+
state.starts,
|
|
113
|
+
(run) => run(ctx),
|
|
114
|
+
(source, error) => {
|
|
115
|
+
state.report(source, `failed to start the session: ${describe(error)}`);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.on("session_shutdown", async () => {
|
|
121
|
+
if (!live) return;
|
|
122
|
+
// Before the hooks: a hook stops a service, and a timer left running would read it after.
|
|
123
|
+
state.stop();
|
|
124
|
+
await runInOrder(
|
|
125
|
+
state.shutdowns.toReversed(),
|
|
126
|
+
(run) => run(),
|
|
127
|
+
(source, error) => {
|
|
128
|
+
state.report(source, `failed to shut down: ${describe(error)}`);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
session = undefined;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// All the switches first, so one read of the file decides what is on.
|
|
135
|
+
const candidates: { feature: Feature; enabled: Setting<boolean> }[] = [];
|
|
136
|
+
for (const feature of [settingsScreen, ...features]) {
|
|
137
|
+
if (candidates.some((candidate) => candidate.feature.id === feature.id)) {
|
|
138
|
+
state.report(feature.id, "was added to this app twice; keeping the first");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const enabled = enabledSetting(feature);
|
|
142
|
+
try {
|
|
143
|
+
settings.register([enabled, ...(feature.settings ?? [])]);
|
|
144
|
+
candidates.push({ feature, enabled });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
state.report(feature.id, `failed to set up: ${describe(error)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const { feature, enabled } of candidates) {
|
|
151
|
+
if (!enabled.get(app)) continue;
|
|
152
|
+
|
|
153
|
+
const owner = ownerOf(pi, feature.id);
|
|
154
|
+
if (owner !== undefined) {
|
|
155
|
+
// Every app brings the screen, and one is enough.
|
|
156
|
+
if (feature !== settingsScreen)
|
|
157
|
+
state.report(feature.id, `already loaded by ${owner}, so this copy stays off`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
feature.setup(createScope(state, feature.id));
|
|
163
|
+
mounted.add(feature.id);
|
|
164
|
+
running.push(feature);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
state.report(feature.id, `failed to set up: ${describe(error)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return app;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function runInOrder<T>(
|
|
174
|
+
hooks: readonly Hook<T>[],
|
|
175
|
+
call: (run: T) => void | Promise<void>,
|
|
176
|
+
failed: (source: string, error: unknown) => void,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
for (const hook of hooks) {
|
|
179
|
+
try {
|
|
180
|
+
// oxlint-disable-next-line no-await-in-loop -- the order is the point
|
|
181
|
+
await call(hook.run);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
failed(hook.source, error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// An app asks the others on pi.events whether one already runs a feature, so the harness and a
|
|
2
|
+
// standalone copy never run it twice. The bus is synchronous, so the answer is written on the payload.
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { isObject, object, string } from "../decode.ts";
|
|
6
|
+
|
|
7
|
+
const CHANNEL = "harness:kit:claim";
|
|
8
|
+
const claimShape = object({ feature: string });
|
|
9
|
+
|
|
10
|
+
export function answerClaims(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
appName: string,
|
|
13
|
+
mounted: ReadonlySet<string>,
|
|
14
|
+
isLive: () => boolean,
|
|
15
|
+
): void {
|
|
16
|
+
pi.events.on(CHANNEL, (data) => {
|
|
17
|
+
const claim = claimShape.decode(data, "");
|
|
18
|
+
if (!isLive() || !claim.ok || !isObject(data) || data.owner !== undefined) return;
|
|
19
|
+
if (mounted.has(claim.value.feature)) data.owner = appName;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ownerOf(pi: ExtensionAPI, featureId: string): string | undefined {
|
|
24
|
+
const claim: { feature: string; owner?: unknown } = { feature: featureId };
|
|
25
|
+
pi.events.emit(CHANNEL, claim);
|
|
26
|
+
return typeof claim.owner === "string" ? claim.owner : undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
RegisteredCommand,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import type { Control, Json } from "../control.ts";
|
|
8
|
+
import type { Setting, SettingsScope } from "../settings/setting.ts";
|
|
9
|
+
import type { SettingsStore } from "../settings/store.ts";
|
|
10
|
+
|
|
11
|
+
export type SessionHook = (ctx: ExtensionContext) => void | Promise<void>;
|
|
12
|
+
export type ShutdownHook = () => void | Promise<void>;
|
|
13
|
+
export type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
|
|
14
|
+
|
|
15
|
+
export interface Feature {
|
|
16
|
+
/** Unique across every package, like `subscription`. It names the feature's settings too. */
|
|
17
|
+
id: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/** The settings tab. Features with the same tab share it. Defaults to the id. */
|
|
20
|
+
tab?: string;
|
|
21
|
+
/** Section order on the tab. Sections not listed come after. A function runs when the screen opens. */
|
|
22
|
+
sections?: readonly string[] | ((ctx: ExtensionContext | undefined) => readonly string[]);
|
|
23
|
+
settings?: readonly Setting<unknown>[];
|
|
24
|
+
setup(scope: FeatureScope): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extends `ExtensionAPI`, since reading it through another type collapses pi's per-event overloads. */
|
|
28
|
+
export interface FeatureScope extends ExtensionAPI, SettingsScope {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly settings: SettingsStore;
|
|
31
|
+
onSessionStart(hook: SessionHook): void;
|
|
32
|
+
/** Runs once per session, newest first. */
|
|
33
|
+
onShutdown(hook: ShutdownHook): void;
|
|
34
|
+
/** Runs once after `ms`, never after the session ends; cancelled before any shutdown hook runs. */
|
|
35
|
+
defer(run: () => void, ms?: number): void;
|
|
36
|
+
/** Shown as `<app>: <feature>: <message>`. */
|
|
37
|
+
warn(message: string): void;
|
|
38
|
+
has(featureId: string): boolean;
|
|
39
|
+
readonly screen: ScreenRows;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ScreenRow {
|
|
43
|
+
id: string;
|
|
44
|
+
section: string;
|
|
45
|
+
label: string;
|
|
46
|
+
description: string;
|
|
47
|
+
/** How deep the row sits under its section. Each step indents two columns. */
|
|
48
|
+
indent?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ScreenValue extends ScreenRow {
|
|
52
|
+
control: Control | ((ctx: ExtensionContext) => Control);
|
|
53
|
+
get(ctx: ExtensionContext): Json;
|
|
54
|
+
/** Returns why the value was refused. */
|
|
55
|
+
set(value: Json, ctx: ExtensionContext): string | undefined;
|
|
56
|
+
/** Where the value applies, when that is not this session. */
|
|
57
|
+
meta?: string;
|
|
58
|
+
/** What the row shows in place of the value, like a count for a list. Undefined shows the value. */
|
|
59
|
+
text?(ctx: ExtensionContext): string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ScreenAction extends ScreenRow {
|
|
63
|
+
text?(ctx: ExtensionContext): string;
|
|
64
|
+
confirm?: string;
|
|
65
|
+
/** Returned text opens in the screen. A throw shows as an error. */
|
|
66
|
+
run(ctx: ExtensionContext): string | undefined | Promise<string | undefined>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ScreenInfo extends ScreenRow {
|
|
70
|
+
text(ctx: ExtensionContext): string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ScreenEntry =
|
|
74
|
+
| ({ kind: "value" } & ScreenValue)
|
|
75
|
+
| ({ kind: "action" } & ScreenAction)
|
|
76
|
+
| ({ kind: "info" } & ScreenInfo);
|
|
77
|
+
|
|
78
|
+
/** Rows a feature builds when the screen opens, for what is only known then. */
|
|
79
|
+
export type ScreenGroup = (ctx: ExtensionContext) => ScreenEntry[];
|
|
80
|
+
|
|
81
|
+
export interface ScreenRows {
|
|
82
|
+
value(row: ScreenValue): void;
|
|
83
|
+
action(row: ScreenAction): void;
|
|
84
|
+
info(row: ScreenInfo): void;
|
|
85
|
+
/** One call, many rows: the provider runs every time the screen opens. */
|
|
86
|
+
rows(provider: ScreenGroup): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function defineFeature(feature: Feature): Feature {
|
|
90
|
+
return feature;
|
|
91
|
+
}
|