pi-weave 0.2.4 → 0.3.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,227 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { findGitRoot } from "./git";
4
+ import { resolveVaultRoot } from "./paths";
5
+ import { buildRepoIndex, summarizeIndex, writeRepoIndex } from "./repoIndex";
6
+ import { formatDashboard, getWorkspaceStatus } from "./workspace";
7
+ import { DEEP_SCAN_SYSTEM_PROMPT, formatDeepScanResult, runDeepScan, type SummarizeFn } from "./summaries";
8
+ import { formatSessionScanResult, renderSessionDigest, runSessionDigestScan, runSessionScan, SESSION_SCAN_SYSTEM_PROMPT, type SessionDigest } from "./sessions";
9
+
10
+ export interface WorkspaceScanStatus {
11
+ sessionID: string;
12
+ text: string;
13
+ active: boolean;
14
+ }
15
+
16
+ export interface WorkspaceCommand {
17
+ name: string;
18
+ description: string;
19
+ execute(sessionID: string, args: string): Promise<void>;
20
+ }
21
+
22
+ export interface WorkspaceCommandHost {
23
+ getSession(sessionID: string): Promise<{ id: string; cwd: string; model: string | null }>;
24
+ getDigest(sessionID: string, signal: AbortSignal): Promise<{ digest: SessionDigest; hash: string }>;
25
+ sessionSource(sessionID: string): string;
26
+ generate(sessionID: string, prompt: string, signal: AbortSignal, model: string): Promise<string>;
27
+ output(sessionID: string, text: string): Promise<unknown>;
28
+ }
29
+
30
+ interface RunningScan {
31
+ controller: AbortController;
32
+ done: Promise<void>;
33
+ }
34
+
35
+ function summarizer(host: WorkspaceCommandHost, sessionID: string, systemPrompt: string, signal: AbortSignal, model: string): SummarizeFn {
36
+ return async ({ path, content }) => {
37
+ signal.throwIfAborted();
38
+ const text = (await host.generate(sessionID, `${systemPrompt}\n\nFile: ${path}\n\n${content}`, signal, model)).trim();
39
+ signal.throwIfAborted();
40
+ if (!text) throw new Error("model returned an empty summary");
41
+ return text;
42
+ };
43
+ }
44
+
45
+ function historyPath(cwd: string, input: string): string {
46
+ const trimmed = input.trim();
47
+ if (trimmed === "~") return homedir();
48
+ if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
49
+ return resolve(cwd, trimmed);
50
+ }
51
+
52
+ export function createWorkspaceCommands(
53
+ host: WorkspaceCommandHost,
54
+ onStatus: (status: WorkspaceScanStatus) => void = () => {},
55
+ viewer?: { run(cwd: string): Promise<{ session: { server: { entryUrl: string } }; started: boolean }> },
56
+ onViewer?: (event: { sessionID: string; url: string; open: boolean; started: boolean }) => void,
57
+ ) {
58
+ const running = new Map<string, RunningScan>();
59
+ const commands: WorkspaceCommand[] = [];
60
+ const output = host.output;
61
+ const cwdOf = async (sessionID: string) => (await host.getSession(sessionID)).cwd;
62
+
63
+ const start = (sessionID: string, run: (signal: AbortSignal) => Promise<string>): void => {
64
+ const key = String(sessionID);
65
+ const controller = new AbortController();
66
+ const done = (async () => {
67
+ try {
68
+ const text = await run(controller.signal);
69
+ await output(sessionID, text);
70
+ onStatus({ sessionID: key, text, active: false });
71
+ } catch (error) {
72
+ const text = controller.signal.aborted
73
+ ? "pi-weave: scan cancelled."
74
+ : `pi-weave: scan failed — ${error instanceof Error ? error.message : String(error)}`;
75
+ await output(sessionID, text).catch(() => {});
76
+ onStatus({ sessionID: key, text, active: false });
77
+ } finally {
78
+ running.delete(key);
79
+ }
80
+ })();
81
+ running.set(key, { controller, done });
82
+ };
83
+
84
+ commands.push({
85
+ name: "weave",
86
+ description: "Show the pi-weave workspace dashboard",
87
+ async execute(sessionID) {
88
+ await output(sessionID, formatDashboard(await getWorkspaceStatus(await cwdOf(sessionID))));
89
+ },
90
+ });
91
+
92
+ commands.push({
93
+ name: "weave-scan",
94
+ description: "Refresh the index; use 'deep' or 'sessions [path]' for model-backed scans",
95
+ async execute(sessionID, args) {
96
+ const [mode = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
97
+ const key = String(sessionID);
98
+ if (running.has(key)) {
99
+ await output(sessionID, "pi-weave: a scan is already running — use /weave-scan-cancel to stop it.");
100
+ return;
101
+ }
102
+ const cwd = await cwdOf(sessionID);
103
+ if (mode.toLowerCase() === "sessions") {
104
+ const session = await host.getSession(sessionID);
105
+ if (!session.model) {
106
+ await output(sessionID, "pi-weave: session scan needs an active session model — none configured.");
107
+ return;
108
+ }
109
+ start(sessionID, async (signal) => {
110
+ const status = "🕸️ session scan: starting…";
111
+ onStatus({ sessionID: key, text: status, active: true });
112
+ if (rest.length > 0) {
113
+ const result = await runSessionScan({
114
+ sessionsRoot: historyPath(cwd, rest.join(" ")),
115
+ vaultRoot: resolveVaultRoot(),
116
+ summarize: summarizer(host, sessionID, SESSION_SCAN_SYSTEM_PROMPT, signal, session.model!),
117
+ model: session.model!,
118
+ signal,
119
+ onProgress: ({ current, total, path }) => {
120
+ onStatus({ sessionID: key, text: `🕸️ session scan: ${current}/${total} — ${path}`, active: true });
121
+ },
122
+ });
123
+ return signal.aborted
124
+ ? "pi-weave: session scan cancelled."
125
+ : `pi-weave: session scan complete — ${formatSessionScanResult(result)}`;
126
+ }
127
+ const { digest, hash } = await host.getDigest(sessionID, signal);
128
+ const result = await runSessionDigestScan({
129
+ vaultRoot: resolveVaultRoot(),
130
+ digest,
131
+ content: renderSessionDigest(digest),
132
+ source: host.sessionSource(session.id),
133
+ hash,
134
+ summarize: summarizer(host, sessionID, SESSION_SCAN_SYSTEM_PROMPT, signal, session.model!),
135
+ model: session.model!,
136
+ });
137
+ return signal.aborted
138
+ ? "pi-weave: session scan cancelled."
139
+ : `pi-weave: session scan complete — ${formatSessionScanResult(result)}`;
140
+ });
141
+ return;
142
+ }
143
+
144
+ const root = await findGitRoot(cwd);
145
+ if (!root) {
146
+ await output(sessionID, "pi-weave: not inside a git repository.");
147
+ return;
148
+ }
149
+ const index = await buildRepoIndex(root);
150
+ if (!index) {
151
+ await output(sessionID, "pi-weave: cannot index — the repository has no commits yet.");
152
+ return;
153
+ }
154
+ await writeRepoIndex(root, index);
155
+ await output(sessionID, `pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`);
156
+ onStatus({ sessionID: key, text: "pi-weave: index refreshed", active: false });
157
+ if (mode.toLowerCase() !== "deep") return;
158
+
159
+ const session = await host.getSession(sessionID);
160
+ if (!session.model) {
161
+ await output(sessionID, "pi-weave: deep scan needs an active session model — none configured. Light index only.");
162
+ return;
163
+ }
164
+ start(sessionID, async (signal) => {
165
+ onStatus({ sessionID: key, text: "🕸️ deep scan: starting…", active: true });
166
+ const result = await runDeepScan(root, {
167
+ summarize: summarizer(host, sessionID, DEEP_SCAN_SYSTEM_PROMPT, signal, session.model!),
168
+ model: session.model!,
169
+ signal,
170
+ onProgress: ({ current, total, path }) => {
171
+ const pct = total > 0 ? Math.round((current / total) * 100) : 100;
172
+ onStatus({ sessionID: key, text: `🕸️ deep scan: ${current}/${total} (${pct}%) — ${path}`, active: true });
173
+ },
174
+ });
175
+ if (signal.aborted) return "pi-weave: deep scan cancelled.";
176
+ return result
177
+ ? `pi-weave: deep scan complete — ${formatDeepScanResult(result)}`
178
+ : "pi-weave: deep scan stopped — repository unavailable.";
179
+ });
180
+ },
181
+ });
182
+
183
+ commands.push({
184
+ name: "weave-scan-cancel",
185
+ description: "Cancel the active pi-weave deep or session scan",
186
+ async execute(sessionID) {
187
+ const scan = running.get(String(sessionID));
188
+ if (!scan) {
189
+ await output(sessionID, "pi-weave: no scan is currently running.");
190
+ return;
191
+ }
192
+ scan.controller.abort();
193
+ await output(sessionID, "pi-weave: scan cancellation requested.");
194
+ },
195
+ });
196
+
197
+ if (viewer) {
198
+ commands.push({
199
+ name: "weave-view",
200
+ description: "Open the pi-weave browser workspace; use --no-open to only print the URL",
201
+ async execute(sessionID, input) {
202
+ const args = input.trim();
203
+ if (args && args !== "--no-open") {
204
+ await output(sessionID, "usage: /weave-view [--no-open]");
205
+ return;
206
+ }
207
+ const { session, started } = await viewer.run(await cwdOf(sessionID));
208
+ const url = session.server.entryUrl;
209
+ const open = args !== "--no-open" && onViewer !== undefined;
210
+ onViewer?.({ sessionID: String(sessionID), url, open, started });
211
+ await output(
212
+ sessionID,
213
+ `pi-weave: workspace ${started ? "running" : "already running"} at ${url}${open ? " — opening when the terminal is local." : " — open it in a browser."}`,
214
+ );
215
+ },
216
+ });
217
+ }
218
+
219
+ return {
220
+ commands,
221
+ done: (sessionID: string) => running.get(sessionID)?.done,
222
+ async cleanup() {
223
+ for (const scan of running.values()) scan.controller.abort();
224
+ await Promise.all([...running.values()].map((scan) => scan.done));
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,138 @@
1
+ import { hashContent } from "../core";
2
+ import type { SessionDigest } from "../core";
3
+ import { createWorkspaceCommands, type WorkspaceScanStatus } from "../core/workspaceCommands";
4
+ import type { WorkspaceServerController } from "../web/server/controller";
5
+
6
+ type Context = import("@opencode/plugin").Plugin.Context;
7
+ type SessionID = Parameters<Context["session"]["get"]>[0]["sessionID"];
8
+ export type OpenCodeScanStatus = WorkspaceScanStatus;
9
+
10
+ function record(value: unknown): Record<string, unknown> {
11
+ return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
12
+ }
13
+
14
+ function timestamp(value: unknown): string {
15
+ return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : "";
16
+ }
17
+
18
+ function clipped(value: unknown, max: number): string {
19
+ if (typeof value !== "string") return "";
20
+ const text = value.trim();
21
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
22
+ }
23
+
24
+ /** Convert OpenCode's public session context to the portable session digest. */
25
+ export function openCodeSessionDigest(session: unknown, messages: readonly unknown[]): SessionDigest {
26
+ const info = record(session);
27
+ const time = record(info.time);
28
+ const location = record(info.location);
29
+ const relevant = messages.filter((message) => ["user", "assistant", "compaction", "shell"].includes(String(record(message).type)));
30
+ const digest: SessionDigest = {
31
+ id: String(info.id ?? ""),
32
+ cwd: typeof location.directory === "string" ? location.directory : "",
33
+ parentSession: typeof info.parentID === "string" ? info.parentID : null,
34
+ startedAt: timestamp(time.created),
35
+ endedAt: timestamp(time.updated),
36
+ name: typeof info.title === "string" && info.title.trim() ? info.title.trim() : null,
37
+ models: [],
38
+ userCount: 0,
39
+ assistantCount: 0,
40
+ toolResultCount: 0,
41
+ errors: 0,
42
+ bashCount: 0,
43
+ tools: {},
44
+ firstUserMessage: null,
45
+ userMessages: [],
46
+ compactions: [],
47
+ branchSummaries: [],
48
+ lastAssistantText: null,
49
+ };
50
+ for (const raw of relevant) {
51
+ const message = record(raw);
52
+ if (message.type === "user") {
53
+ digest.userCount += 1;
54
+ const text = clipped(message.text, 400);
55
+ if (text) {
56
+ digest.userMessages.push(text);
57
+ if (digest.userMessages.length > 60) digest.userMessages.shift();
58
+ digest.firstUserMessage ??= clipped(text, 120);
59
+ }
60
+ continue;
61
+ }
62
+ if (message.type === "shell") {
63
+ digest.bashCount += 1;
64
+ continue;
65
+ }
66
+ if (message.type === "compaction") {
67
+ const summary = clipped(message.summary, 1_200);
68
+ if (summary) {
69
+ digest.compactions.push(summary);
70
+ if (digest.compactions.length > 3) digest.compactions.shift();
71
+ }
72
+ continue;
73
+ }
74
+ digest.assistantCount += 1;
75
+ const model = record(message.model);
76
+ if (typeof model.providerID === "string" && typeof model.id === "string") {
77
+ const label = `${model.providerID}/${model.id}`;
78
+ if (!digest.models.includes(label)) digest.models.push(label);
79
+ }
80
+ if (message.finish === "error" || message.error) digest.errors += 1;
81
+ const content = Array.isArray(message.content) ? message.content : [];
82
+ const text = content
83
+ .filter((part) => record(part).type === "text")
84
+ .map((part) => record(part).text)
85
+ .filter((part): part is string => typeof part === "string")
86
+ .join("\n");
87
+ if (text.trim()) digest.lastAssistantText = clipped(text, 1_200);
88
+ for (const part of content) {
89
+ const tool = record(part);
90
+ if (tool.type !== "tool") continue;
91
+ digest.toolResultCount += 1;
92
+ const name = typeof tool.name === "string" ? tool.name : "unknown";
93
+ digest.tools[name] = (digest.tools[name] ?? 0) + 1;
94
+ if (record(tool.state).status === "error") digest.errors += 1;
95
+ }
96
+ }
97
+ return digest;
98
+ }
99
+
100
+ export async function registerOpenCodeCommands(
101
+ context: Context,
102
+ onStatus: (status: OpenCodeScanStatus) => void = () => {},
103
+ viewer?: WorkspaceServerController,
104
+ onViewer: (event: { sessionID: string; url: string; open: boolean; started: boolean }) => void = () => {},
105
+ ) {
106
+ const shared = createWorkspaceCommands({
107
+ async getSession(id) {
108
+ const session = await context.session.get({ sessionID: id as SessionID });
109
+ return { id: session.id, cwd: session.location.directory, model: session.model ? `${session.model.providerID}/${session.model.id}` : null };
110
+ },
111
+ async getDigest(id, signal) {
112
+ const sessionID = id as SessionID;
113
+ const session = await context.session.get({ sessionID });
114
+ const messages = await context.session.context({ sessionID }, { signal });
115
+ const relevant = messages.filter((message) => ["user", "assistant", "compaction", "shell"].includes(message.type));
116
+ return { digest: openCodeSessionDigest(session, relevant), hash: hashContent(JSON.stringify(relevant)) };
117
+ },
118
+ sessionSource: (id) => `opencode:session:${id}`,
119
+ async generate(id, prompt, signal) {
120
+ return (await context.session.generate({ sessionID: id as SessionID, prompt }, { signal })).text;
121
+ },
122
+ output: (id, text) => context.session.synthetic({ sessionID: id as SessionID, text, description: "pi-weave", resume: false }),
123
+ }, onStatus, viewer, onViewer);
124
+ const registration = await context.command.transform((editor) => {
125
+ for (const command of shared.commands) editor.add({
126
+ name: command.name,
127
+ description: command.description,
128
+ execute: ({ sessionID, prompt }) => command.execute(sessionID, prompt.text),
129
+ });
130
+ });
131
+ return {
132
+ done: shared.done,
133
+ async cleanup() {
134
+ await shared.cleanup();
135
+ await registration.dispose();
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,88 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { formatStatusLine, getWorkspaceStatus } from "../core";
4
+ import { parseFrontMatter, unquoteField } from "../core/frontmatter";
5
+ import { registerOpenCodeCommands } from "./commands";
6
+ import { weaveTools } from "./tools";
7
+ import { WEAVE_RPC } from "./rpc";
8
+ import { WorkspaceServerController } from "../web/server/controller";
9
+
10
+ async function packagedSkill(relativePath: string): Promise<import("@opencode/plugin").Skill.Info> {
11
+ const path = fileURLToPath(new URL(`../../skills/${relativePath}/SKILL.md`, import.meta.url));
12
+ const content = await readFile(path, "utf8");
13
+ const parsed = parseFrontMatter(content);
14
+ const name = parsed?.fields.get("name");
15
+ if (!parsed || !name) throw new Error(`Invalid packaged skill: ${relativePath}`);
16
+ return {
17
+ id: name,
18
+ name,
19
+ description: unquoteField(parsed.fields.get("description") ?? ""),
20
+ path,
21
+ content: parsed.body,
22
+ } as import("@opencode/plugin").Skill.Info;
23
+ }
24
+
25
+ const weave = {
26
+ id: "pi-weave",
27
+ server: async (input: import("@opencode-ai/plugin").PluginInput) => (await import("./v1")).server(input),
28
+ async setup(context) {
29
+ const toolRegistration = await context.tool.transform((editor) => {
30
+ for (const tool of weaveTools) editor.add({
31
+ name: tool.name,
32
+ description: tool.description,
33
+ input: tool.input,
34
+ options: { codemode: false },
35
+ async execute(input, toolContext) {
36
+ const session = await context.session.get({ sessionID: toolContext.sessionID });
37
+ const result = await tool.execute(input, session.location.directory, (status) => toolContext.progress({ status }));
38
+ await refreshStatus(toolContext.sessionID);
39
+ return { content: result.text, metadata: result.details };
40
+ },
41
+ });
42
+ });
43
+
44
+ const skills = await Promise.all([packagedSkill("weave-notepad"), packagedSkill("weave-explore")]);
45
+ const skillRegistration = await context.skill.transform((editor) => {
46
+ for (const skill of skills) editor.add(skill);
47
+ });
48
+ const viewer = new WorkspaceServerController();
49
+ let status: { text: string; active: boolean; sessionID?: string; viewerUrl?: string } = {
50
+ text: formatStatusLine(await getWorkspaceStatus(context.location.directory)),
51
+ active: false,
52
+ };
53
+ const rpc = await context.rpc.register(WEAVE_RPC, {
54
+ status: async () => status,
55
+ });
56
+ const publishStatus = (next: Partial<typeof status>) => {
57
+ status = { ...status, ...next };
58
+ void rpc.events.emit("status", status).catch(() => {});
59
+ };
60
+ const refreshStatus = async (sessionID: Parameters<typeof context.session.get>[0]["sessionID"]) => {
61
+ const session = await context.session.get({ sessionID });
62
+ const text = formatStatusLine(await getWorkspaceStatus(session.location.directory));
63
+ if (!status.active) publishStatus({ sessionID, text, active: false });
64
+ };
65
+ const commands = await registerOpenCodeCommands(
66
+ context,
67
+ (next) => {
68
+ publishStatus(next);
69
+ if (!next.active) void refreshStatus(next.sessionID as Parameters<typeof context.session.get>[0]["sessionID"]).catch(() => {});
70
+ },
71
+ viewer,
72
+ (event) => {
73
+ status = { ...status, viewerUrl: event.url };
74
+ void rpc.events.emit("viewer", event).catch(() => {});
75
+ void rpc.events.emit("status", status).catch(() => {});
76
+ },
77
+ );
78
+ return async () => {
79
+ await commands.cleanup();
80
+ await viewer.close();
81
+ await rpc.dispose();
82
+ await skillRegistration.dispose();
83
+ await toolRegistration.dispose();
84
+ };
85
+ },
86
+ } satisfies import("@opencode/plugin").Plugin.Plugin & Pick<import("@opencode-ai/plugin").PluginModule, "server">;
87
+
88
+ export default weave;
@@ -0,0 +1,37 @@
1
+ const statusSchema = {
2
+ type: "object",
3
+ properties: {
4
+ sessionID: { type: "string" },
5
+ text: { type: "string" },
6
+ active: { type: "boolean" },
7
+ viewerUrl: { type: "string" },
8
+ },
9
+ required: ["text", "active"],
10
+ additionalProperties: false,
11
+ } as const;
12
+
13
+ export const WEAVE_RPC = {
14
+ id: "pi-weave",
15
+ methods: {
16
+ status: {
17
+ input: { type: "object", properties: {}, additionalProperties: false },
18
+ output: statusSchema,
19
+ },
20
+ },
21
+ events: {
22
+ status: { schema: statusSchema },
23
+ viewer: {
24
+ schema: {
25
+ type: "object",
26
+ properties: {
27
+ sessionID: { type: "string" },
28
+ url: { type: "string" },
29
+ open: { type: "boolean" },
30
+ started: { type: "boolean" },
31
+ },
32
+ required: ["sessionID", "url", "open", "started"],
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ },
37
+ } as const;
@@ -0,0 +1,43 @@
1
+ import { executeNoteAction, executeRepoAction, WEAVE_NOTE_DESCRIPTION, WEAVE_REPO_DESCRIPTION, type NoteActionInput, type RepoActionInput } from "../core";
2
+
3
+ const noteInput = {
4
+ type: "object",
5
+ properties: {
6
+ action: { type: "string", enum: ["list", "get", "add", "append", "finalize", "search", "links", "suggest"] },
7
+ title: { type: "string", description: "Note title (add)" },
8
+ text: { type: "string", description: "Markdown body (add), addition (append), or restructured body (finalize)" },
9
+ tags: { type: "array", items: { type: "string" }, description: "Tags (add)" },
10
+ slug: { type: "string", description: "Note slug (get, append, finalize)" },
11
+ raw: { type: "boolean", description: "append: preserve text verbatim in the ## Raw tail" },
12
+ source: { type: "string", enum: ["human", "agent"], description: "Provenance (add; defaults to agent)" },
13
+ query: { type: "string", description: "Search query (search)" },
14
+ fix: { type: "boolean", description: "links: apply unambiguous repairs" },
15
+ limit: { type: "number", description: "suggest: maximum suggestions (default 20)" },
16
+ },
17
+ required: ["action"],
18
+ additionalProperties: false,
19
+ } as const;
20
+
21
+ const repoInput = {
22
+ type: "object",
23
+ properties: {
24
+ action: { type: "string", enum: ["status", "scan", "overview"] },
25
+ },
26
+ required: ["action"],
27
+ additionalProperties: false,
28
+ } as const;
29
+
30
+ export const weaveTools = [
31
+ {
32
+ name: "weave_note",
33
+ description: WEAVE_NOTE_DESCRIPTION,
34
+ input: noteInput,
35
+ execute: (input: unknown, _cwd: string, _progress?: (text: string) => Promise<void>) => executeNoteAction(input as NoteActionInput),
36
+ },
37
+ {
38
+ name: "weave_repo",
39
+ description: WEAVE_REPO_DESCRIPTION,
40
+ input: repoInput,
41
+ execute: (input: unknown, cwd: string, progress?: (text: string) => Promise<void>) => executeRepoAction(input as RepoActionInput, cwd, progress),
42
+ },
43
+ ];
@@ -0,0 +1,96 @@
1
+ import { execFile } from "node:child_process";
2
+ import { browserOpenCommand } from "../web/server/controller";
3
+ import { WEAVE_RPC } from "./rpc";
4
+
5
+ function record(value: unknown): Record<string, unknown> {
6
+ return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
7
+ }
8
+
9
+ export async function viewerIsLocal(url: string, request: typeof fetch = fetch): Promise<boolean> {
10
+ try {
11
+ const target = new URL(url);
12
+ if (target.protocol !== "http:" || !["127.0.0.1", "::1", "localhost"].includes(target.hostname)) return false;
13
+ const response = await request(target, { redirect: "manual", signal: AbortSignal.timeout(1_000) });
14
+ await response.body?.cancel().catch(() => undefined);
15
+ return response.status === 302 && response.headers.has("set-cookie");
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ async function openBrowser(url: string): Promise<boolean> {
22
+ const { command, args } = browserOpenCommand(url);
23
+ return new Promise((resolve) => {
24
+ execFile(command, args, (error) => resolve(error === null));
25
+ });
26
+ }
27
+
28
+ async function viewerNotice(url: string, open: boolean) {
29
+ const opened = open && await viewerIsLocal(url) && await openBrowser(url);
30
+ return {
31
+ message: opened
32
+ ? `Workspace opened at ${url}`
33
+ : `Workspace running at ${url}${open ? " — open this URL from the server host or through a tunnel." : ""}`,
34
+ variant: opened ? "success" as const : "info" as const,
35
+ };
36
+ }
37
+
38
+ const weaveTui = {
39
+ id: "pi-weave.tui",
40
+ async tui(api) {
41
+ api.lifecycle.onDispose(api.event.on("message.part.updated", async ({ properties: { part } }) => {
42
+ if (part.type !== "text" || !part.synthetic) return;
43
+ const result = record(part.metadata?.["pi-weave-result"]);
44
+ if (typeof result.text !== "string") return;
45
+ const route = api.route.current;
46
+ if (route.name === "session" && route.params?.sessionID !== part.sessionID) return;
47
+ const viewer = record(result.viewer);
48
+ if (typeof viewer.url !== "string") return;
49
+ const notice = await viewerNotice(viewer.url, viewer.open === true);
50
+ api.ui.toast({ title: "pi-weave", ...notice });
51
+ if (notice.variant === "success") return;
52
+ api.ui.dialog.replace(() => api.ui.DialogAlert({ title: "pi-weave", message: result.text as string }));
53
+ }));
54
+ },
55
+ async setup(context) {
56
+ const rpc = context.client.rpc(WEAVE_RPC);
57
+ try {
58
+ const initial = await rpc.status({});
59
+ const text = String(record(initial).text ?? "");
60
+ if (text.includes("repo:unindexed")) {
61
+ context.ui.toast.show({ message: "This repository is not indexed yet. Run /weave-scan.", variant: "info" });
62
+ } else if (text.includes(":stale")) {
63
+ context.ui.toast.show({ message: "The pi-weave repository index is stale. Run /weave-scan.", variant: "warning" });
64
+ }
65
+ } catch {
66
+ context.ui.toast.show({ message: "pi-weave unavailable", variant: "warning" });
67
+ }
68
+
69
+ let lastProgress = 0;
70
+ const stopStatus = rpc.events.on("status", (event) => {
71
+ const data = record(event.data);
72
+ if (data.active === true && typeof data.text === "string" && Date.now() - lastProgress >= 2_000) {
73
+ lastProgress = Date.now();
74
+ context.ui.toast.show({ message: data.text, variant: "info", duration: 2_000, ...(typeof data.sessionID === "string" ? { sessionID: data.sessionID } : {}) });
75
+ }
76
+ if (data.active === false && typeof data.text === "string" && data.text.includes("complete")) {
77
+ context.ui.toast.show({ message: data.text, variant: "success", ...(typeof data.sessionID === "string" ? { sessionID: data.sessionID } : {}) });
78
+ }
79
+ });
80
+ const stopViewer = rpc.events.on("viewer", async (event) => {
81
+ const data = record(event.data);
82
+ if (typeof data.url !== "string") return;
83
+ context.ui.toast.show({
84
+ title: "pi-weave",
85
+ ...await viewerNotice(data.url, data.open === true),
86
+ ...(typeof data.sessionID === "string" ? { sessionID: data.sessionID } : {}),
87
+ });
88
+ });
89
+ return () => {
90
+ stopViewer();
91
+ stopStatus();
92
+ };
93
+ },
94
+ } satisfies import("@opencode/plugin/tui").Plugin.Definition & Pick<import("@opencode-ai/plugin/tui").TuiPluginModule, "tui">;
95
+
96
+ export default weaveTui;