@vzlentin/harness 0.1.0 → 0.2.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.
package/dist/setup.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Registers the Pi extension of a globally installed package in Pi. The `postinstall` script and the CLI of the package
3
+ * run it, since npm 12 does not run install scripts by default. workflows uses it too, as `@vzlentin/harness/setup`.
4
+ */
5
+ import { spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, delimiter, dirname, join, sep } from "node:path";
10
+ import { Type } from "typebox";
11
+ import { Value } from "typebox/value";
12
+ const PackageJson = Type.Object({
13
+ name: Type.String(),
14
+ version: Type.String(),
15
+ retiredPiPackages: Type.Optional(Type.Array(Type.String())),
16
+ });
17
+ const PiSettings = Type.Object({
18
+ packages: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ source: Type.String() })]))),
19
+ });
20
+ /**
21
+ * Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. First it runs `pi remove` for
22
+ * each Pi package of the `retiredPiPackages` field of the package's `package.json` that Pi's settings list. It writes
23
+ * its messages to stderr, and it does not throw for a Pi failure: it writes a warning with the command to run.
24
+ * It writes the stamp that `setupOnce` reads unless a `pi` command failed, so the next run tries again. Without `pi`
25
+ * on PATH it writes the stamp, so a user without Pi gets the hint once per version.
26
+ */
27
+ export function setup(packageDir) {
28
+ const installed = readInstalled(packageDir);
29
+ if (installed === undefined || !isTopLevelGlobal(installed))
30
+ return;
31
+ if (registerExtension(installed))
32
+ writeStamp(installed);
33
+ }
34
+ /**
35
+ * `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
36
+ * throws; an error is a warning.
37
+ */
38
+ export function setupOnce(packageDir) {
39
+ try {
40
+ const installed = readInstalled(packageDir);
41
+ if (installed === undefined || !isTopLevelGlobal(installed) || existsSync(stampPath(installed)))
42
+ return;
43
+ if (registerExtension(installed))
44
+ writeStamp(installed);
45
+ }
46
+ catch (error) {
47
+ process.stderr.write(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}\n`);
48
+ }
49
+ }
50
+ /**
51
+ * True when `dir` is `<global root>/<name>`: `<root>` is a `node_modules` folder in `lib` (in the prefix on Windows),
52
+ * and has no other `node_modules` above it. A copy nested in another package is not top-level.
53
+ */
54
+ function isTopLevelGlobal({ dir, json }) {
55
+ const name = json.name.split("/").join(sep);
56
+ if (!dir.endsWith(`${sep}${name}`))
57
+ return false;
58
+ const root = dir.slice(0, -name.length - 1);
59
+ const segments = root.split(sep);
60
+ if (segments.at(-1) !== "node_modules" || segments.indexOf("node_modules") !== segments.length - 1)
61
+ return false;
62
+ return process.platform === "win32" || segments.at(-2) === "lib";
63
+ }
64
+ function readInstalled(packageDir) {
65
+ const dir = realpathSync(packageDir);
66
+ const json = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
67
+ return Value.Check(PackageJson, json) ? { dir, json } : undefined;
68
+ }
69
+ /** The stamp of `setup`: `$XDG_STATE_HOME/harness/.setup/<hash of path and version>`. */
70
+ function stampPath({ dir, json }) {
71
+ const hash = createHash("sha256").update(`${dir}\n${json.version}`).digest("hex");
72
+ return join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "harness", ".setup", hash);
73
+ }
74
+ function writeStamp(installed) {
75
+ const path = stampPath(installed);
76
+ mkdirSync(dirname(path), { recursive: true });
77
+ writeFileSync(path, `${installed.dir}\n${installed.json.version}\n`);
78
+ }
79
+ /** False when a `pi` command failed. */
80
+ function registerExtension({ dir, json }) {
81
+ const label = basename(json.name);
82
+ const extension = join(dir, "pi");
83
+ const later = `To add the Pi extension of ${label}, run: pi install ${extension}`;
84
+ const listed = piPackages();
85
+ for (const retired of json.retiredPiPackages ?? []) {
86
+ for (const source of listed.filter((item) => item === retired || item.startsWith(`${retired}@`))) {
87
+ process.stderr.write(`${label}: removing ${source} from Pi, which ${json.name} replaces\n`);
88
+ if (!runPi(label, ["remove", source], `To remove it, run: pi remove ${source}`))
89
+ return false;
90
+ }
91
+ }
92
+ return runPi(label, ["install", extension], later);
93
+ }
94
+ /** Runs `pi`, with a warning when it is missing or fails. False when it failed; true when it worked or is missing. */
95
+ function runPi(label, args, later) {
96
+ const result = spawnSync("pi", args, {
97
+ env: { ...process.env, PATH: userPath() },
98
+ stdio: ["ignore", 2, 2],
99
+ timeout: 120_000,
100
+ });
101
+ if (result.error !== undefined && "code" in result.error && result.error.code === "ENOENT") {
102
+ process.stderr.write(`${label}: pi is not on PATH. ${later}\n`);
103
+ return true;
104
+ }
105
+ if (result.error !== undefined || result.status !== 0) {
106
+ const reason = result.error?.message ?? `exit ${result.status ?? result.signal}`;
107
+ process.stderr.write(`${label}: pi ${args[0]} failed (${reason}). ${later}\n`);
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+ /** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
113
+ function userPath() {
114
+ return (process.env.PATH ?? "")
115
+ .split(delimiter)
116
+ .filter((entry) => !entry.endsWith(join("node_modules", ".bin")))
117
+ .join(delimiter);
118
+ }
119
+ /** The sources of the packages in Pi's global settings, or none when the file is missing or not valid. */
120
+ function piPackages() {
121
+ const env = process.env.PI_CODING_AGENT_DIR;
122
+ const agentDir = env ? env.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".pi", "agent");
123
+ let settings;
124
+ try {
125
+ settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ if (!Value.Check(PiSettings, settings))
131
+ return [];
132
+ return (settings.packages ?? []).map((item) => (Value.Check(Type.String(), item) ? item : item.source));
133
+ }
package/dist/tasks.js CHANGED
@@ -42,14 +42,8 @@ export async function taskInfo(harness, key) {
42
42
  const record = await harness.getTask(id, context);
43
43
  if (record === undefined)
44
44
  throw new Error(`Missing task ${id}`);
45
- return {
46
- key,
47
- id,
48
- kind: record.kind,
49
- version: record.version,
50
- status: record.state.status,
51
- ...(record.state.status === "terminal" ? { outcome: record.state.outcome } : {}),
52
- };
45
+ const info = { key, id, kind: record.kind, version: record.version, status: record.state.status };
46
+ return record.state.status === "terminal" ? { ...info, outcome: record.state.outcome } : info;
53
47
  }
54
48
  /** Waits until the task of `key` is terminal, or the connection closes, and gives its info with the outcome. */
55
49
  export async function waitTask(host, key) {
@@ -74,5 +68,7 @@ function startableTask(startable, kind) {
74
68
  const task = startable.tasks?.find((found) => found.definition.name === kind);
75
69
  if (task === undefined)
76
70
  throw new Error(`Unknown task kind: ${kind}`);
71
+ // SAFETY: `AnyTask` is a `Task` with its types erased. The store keeps the input, the phase state and the result of
72
+ // every task as JSON.
77
73
  return task;
78
74
  }
package/dist/theme.js CHANGED
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  // Pi gives its theme object only to extensions. Pi's cards color with the theme that `initTheme()` sets in this module,
10
10
  // so the viewer loads the same module by its path to color its own lines the same way. The path is not in the `exports`
11
11
  // of pi-coding-agent, so it holds only for the pinned version 1.3.0.
12
+ // SAFETY: this module of pi-coding-agent 1.3.0, the version that package.json pins, exports the `theme` of Pi's cards.
12
13
  const piTheme = (await import(__rewriteRelativeImportExtension(new URL("./modes/interactive/theme/theme.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href)));
13
14
  export function color(name, text) {
14
15
  return piTheme.theme.fg(name, text);
package/dist/tool.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { Static } from "@earendil-works/pi-ai";
4
4
  import type * as Durable from "@earendil-works/pi-durable";
5
5
  import type { Extension, ToolExecutionApi } from "@earendil-works/pi-durable";
6
6
  import { type Replies, type Request } from "./protocol.ts";
7
- export declare const TOOL_DESCRIPTION = "Spawn and manage subagents: separate agents with their own context, for second opinions, independent investigations and delegated work.\n- spawn: start a child with `prompts` and `files`. It uses your provider, model and thinking level unless you give others. Give it a short `name`. It prints `Spawned <name> (<handle>)`. The handle is the short hex ID in parentheses, not the name.\n- status, wait, send, rename, stop: act on the child `handle`. `wait` waits until the child answers its last message (up to `timeout` seconds, default 1800) and gives the answer. `stop` also stops the children the child spawned. `send` steers a busy child, or queues `message` with `followUp`.\n- list: your children.\nCalls in one message run one after another, in message order, so a `wait` after a `send` gives the answer to that message.\nChildren persist after a turn. Stop each child when you no longer need it.";
7
+ export declare const TOOL_DESCRIPTION = "Spawn and manage subagents: separate agents with their own context, for second opinions, independent investigations and delegated work.\n- spawn: start a child with `prompts` and `files`. It uses your provider, model and thinking level unless you give others. Give it a short `name`. It prints `Spawned <name> (<handle>)`. The handle is the short hex ID in parentheses, not the name.\n- status, wait, send, rename, stop: act on the child `handle`. `wait` waits until the child answers its last message (up to `timeout` seconds, default 1800) and gives the answer, after the earlier answers of the spawn and the sends that no `wait` gave. `stop` also stops the children the child spawned. `send` steers a busy child, or queues `message` with `followUp`.\n- list: your children.\nCalls in one message run one after another, in message order, so a `wait` after a `send` gives the answer to that message.\nChildren persist after a turn. Stop each child when you no longer need it.";
8
8
  /** The parameters and the output schema of the tool, built with the `Type` of the caller's pi-ai. */
9
9
  export declare function toolSchemas(Type: typeof Ai.Type): {
10
10
  parameters: Ai.Type.TObject<{
package/dist/tool.js CHANGED
@@ -7,7 +7,7 @@ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhi
7
7
  const NO_ABORT = new AbortController().signal;
8
8
  export const TOOL_DESCRIPTION = `Spawn and manage subagents: separate agents with their own context, for second opinions, independent investigations and delegated work.
9
9
  - spawn: start a child with \`prompts\` and \`files\`. It uses your provider, model and thinking level unless you give others. Give it a short \`name\`. It prints \`Spawned <name> (<handle>)\`. The handle is the short hex ID in parentheses, not the name.
10
- - status, wait, send, rename, stop: act on the child \`handle\`. \`wait\` waits until the child answers its last message (up to \`timeout\` seconds, default 1800) and gives the answer. \`stop\` also stops the children the child spawned. \`send\` steers a busy child, or queues \`message\` with \`followUp\`.
10
+ - status, wait, send, rename, stop: act on the child \`handle\`. \`wait\` waits until the child answers its last message (up to \`timeout\` seconds, default 1800) and gives the answer, after the earlier answers of the spawn and the sends that no \`wait\` gave. \`stop\` also stops the children the child spawned. \`send\` steers a busy child, or queues \`message\` with \`followUp\`.
11
11
  - list: your children.
12
12
  Calls in one message run one after another, in message order, so a \`wait\` after a \`send\` gives the answer to that message.
13
13
  Children persist after a turn. Stop each child when you no longer need it.`;
@@ -140,19 +140,23 @@ function spawnRequest(args, scope, requestId) {
140
140
  let prompt = files.map(fileText).join("");
141
141
  if (prompts.length > 0)
142
142
  prompt += `Task:\n${prompts.join("\n\n")}`;
143
- return {
143
+ let request = {
144
144
  type: "spawn",
145
145
  requestId,
146
146
  caller: scope.caller,
147
- ...(name === undefined ? {} : { name }),
148
147
  provider,
149
148
  model,
150
- ...(args.thinking === undefined ? {} : { thinking: args.thinking }),
151
149
  callerThinking: scope.thinking,
152
150
  cwd,
153
- ...(tools === undefined ? {} : { tools }),
154
151
  prompt,
155
152
  };
153
+ if (name !== undefined)
154
+ request = { ...request, name };
155
+ if (args.thinking !== undefined)
156
+ request = { ...request, thinking: args.thinking };
157
+ if (tools !== undefined)
158
+ request = { ...request, tools };
159
+ return request;
156
160
  }
157
161
  /** The text of `file` in a `<file>` block. An empty file gives nothing. */
158
162
  function fileText(file) {
@@ -2,11 +2,14 @@
2
2
  * What the viewer reads from the documents of a durable conversation view: its live state, its inbox and the texts of
3
3
  * its user messages.
4
4
  */
5
+ import type { JsonRepresentation } from "@earendil-works/chord";
5
6
  import type { UserMessage } from "@earendil-works/pi-ai";
6
- import { type ConversationView, type InboxState, type LiveState } from "@earendil-works/pi-durable";
7
+ import { type ConversationDocToken, type ConversationView, type InboxState, type JsonObject, type LiveState } from "@earendil-works/pi-durable";
8
+ /** The built-in document of `token` in `view`, if the conversation has it. */
9
+ export declare function docOf<T extends JsonObject>(view: ConversationView, token: ConversationDocToken<T>): T | undefined;
7
10
  export declare function liveOf(view: ConversationView): LiveState;
8
11
  export declare function inboxOf(view: ConversationView): InboxState;
9
- export declare function userText(content: UserMessage["content"]): string;
12
+ export declare function userText(content: JsonRepresentation<UserMessage["content"]>): string;
10
13
  /** The queued steers and follow-ups, in inbox order. */
11
14
  export declare function queued(inbox: InboxState): {
12
15
  mode: "steer" | "followUp";
@@ -1,12 +1,17 @@
1
- import { InboxDoc, LiveDoc } from "@earendil-works/pi-durable";
1
+ import { InboxDoc, LiveDoc, } from "@earendil-works/pi-durable";
2
+ /** The built-in document of `token` in `view`, if the conversation has it. */
3
+ export function docOf(view, token) {
4
+ // SAFETY: pi-durable keeps each built-in conversation document in `docs` under the kind of its token.
5
+ return view.docs[token.definition.kind];
6
+ }
2
7
  export function liveOf(view) {
3
- return view.docs[LiveDoc.definition.kind] ?? {};
8
+ return docOf(view, LiveDoc) ?? {};
4
9
  }
5
10
  export function inboxOf(view) {
6
- return view.docs[InboxDoc.definition.kind] ?? { items: [] };
11
+ return docOf(view, InboxDoc) ?? { items: [] };
7
12
  }
8
13
  export function userText(content) {
9
- if (typeof content === "string")
14
+ if (!Array.isArray(content))
10
15
  return content;
11
16
  return content
12
17
  .filter((block) => block.type === "text")
package/dist/viewer.js CHANGED
@@ -300,8 +300,8 @@ class Viewer {
300
300
  await this.reporter.release();
301
301
  this.#exit(code);
302
302
  }
303
- showError = (error) => {
304
- this.notice.setText(color("error", `Error: ${error instanceof Error ? error.message : String(error)}`));
303
+ showError = (cause) => {
304
+ this.notice.setText(color("error", `Error: ${cause instanceof Error ? cause.message : String(cause)}`));
305
305
  this.tui.requestRender();
306
306
  };
307
307
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzlentin/harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A durable agent harness on Pi, starting with observable, persistent subagents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,9 @@
18
18
  "./package.json": "./package.json"
19
19
  },
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "pi",
23
+ "scripts/postinstall.js"
22
24
  ],
23
25
  "engines": {
24
26
  "node": ">=22.19.0"
@@ -26,7 +28,8 @@
26
28
  "scripts": {
27
29
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
28
30
  "prepack": "npm run build",
29
- "check": "biome check . && tsc -p tsconfig.json && vitest --run --passWithNoTests",
31
+ "postinstall": "node scripts/postinstall.js",
32
+ "check": "biome check . && oxlint --deny-warnings && npm run build && tsc -p tsconfig.json && vitest --run --passWithNoTests",
30
33
  "test": "vitest --run --passWithNoTests"
31
34
  },
32
35
  "dependencies": {
@@ -35,14 +38,20 @@
35
38
  "@earendil-works/pi-coding-agent": "1.3.0",
36
39
  "@earendil-works/pi-durable": "1.3.0",
37
40
  "@earendil-works/pi-tui": "1.3.0",
38
- "jiti": "2.7.0"
41
+ "jiti": "2.7.0",
42
+ "typebox": "1.3.27"
39
43
  },
40
44
  "devDependencies": {
41
45
  "@biomejs/biome": "2.5.15",
46
+ "@oxlint/plugins": "1.88.0",
42
47
  "@types/node": "26.6.4",
48
+ "oxlint": "1.88.0",
43
49
  "typescript": "7.0.2",
44
50
  "vitest": "5.0.3"
45
51
  },
52
+ "retiredPiPackages": [
53
+ "npm:@vzlentin/pi-subagent"
54
+ ],
46
55
  "publishConfig": {
47
56
  "access": "public"
48
57
  }
package/pi/index.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The `subagent` tool, `/subagent` and the subagents widget. They use the client of the harness package that holds
3
+ * this folder, from its built `dist/`, and start the host of that package.
4
+ */
5
+ import { resolve } from "node:path";
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import {
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ getAgentDir,
11
+ hasTrustRequiringProjectResources,
12
+ ProjectTrustStore,
13
+ SettingsManager,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { request } from "../dist/client.js";
16
+ import { openViewerTab } from "../dist/herdr.js";
17
+ import { childLine, runDisplayName } from "../dist/protocol.js";
18
+ import * as tool from "../dist/tool.js";
19
+
20
+ const SESSION_TRUST_HINT =
21
+ "The child gets no project skills (.pi/skills, .agents/skills): this project is trusted for this Pi session only, and children see only the trust saved for future sessions. To give children project skills, run /trust and choose Trust.";
22
+
23
+ export default function subagentExtension(pi: ExtensionAPI) {
24
+ const { parameters, output } = tool.toolSchemas(Type);
25
+ pi.registerTool({
26
+ name: "subagent",
27
+ label: "Subagent",
28
+ description: tool.TOOL_DESCRIPTION,
29
+ parameters,
30
+ outputSchema: output,
31
+ executionMode: "sequential",
32
+ async execute(toolCallId, args, signal, _onUpdate, ctx) {
33
+ const { text, data } = await tool.runTool(args, {
34
+ caller: ctx.sessionManager.getSessionId(),
35
+ callKey: toolCallId,
36
+ provider: ctx.model?.provider,
37
+ model: ctx.model?.id,
38
+ thinking: pi.getThinkingLevel(),
39
+ cwd: ctx.cwd,
40
+ send: tool.sendToHost,
41
+ signal,
42
+ });
43
+
44
+ // Children follow only the saved trust, so a session that trusts its project for itself says what the child misses.
45
+ const sessionOnly =
46
+ args.action === "spawn" &&
47
+ ctx.isProjectTrusted() &&
48
+ !savedTrust(ctx.cwd) &&
49
+ !savedTrust(resolve(ctx.cwd, args.cwd ?? "."));
50
+
51
+ const hint = sessionOnly ? `\n${SESSION_TRUST_HINT}\n` : "";
52
+
53
+ return { content: [{ type: "text", text: text + hint }], details: undefined, structuredContent: data };
54
+ },
55
+ });
56
+
57
+ pi.registerCommand("subagent", {
58
+ description: "Open the viewer of a subagent spawned by this session",
59
+ handler: async (_args, ctx) => {
60
+ const children = await request({ type: "list", caller: ctx.sessionManager.getSessionId() }, { start: true });
61
+
62
+ if (children.length === 0) {
63
+ ctx.ui.notify("No subagents spawned by this session", "info");
64
+
65
+ return;
66
+ }
67
+
68
+ const chosen = await ctx.ui.select("Attach to subagent", children.map(childLine));
69
+
70
+ // A name can start with the display name of another child, so the longest match is the chosen child.
71
+ const child = children
72
+ .filter((c: { handle: string; name?: string }) => chosen?.startsWith(`${runDisplayName(c)} `))
73
+ .sort(
74
+ (a: { handle: string; name?: string }, b: { handle: string; name?: string }) =>
75
+ runDisplayName(b).length - runDisplayName(a).length,
76
+ )[0];
77
+
78
+ if (child === undefined) return;
79
+ const command = `harness attach ${child.handle}`;
80
+
81
+ if (!process.env.HERDR_ENV) {
82
+ ctx.ui.notify(`Run in another terminal: ${command}`, "info");
83
+
84
+ return;
85
+ }
86
+
87
+ try {
88
+ await openViewerTab(child, process.env);
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ ctx.ui.notify(`Could not open a Herdr tab: ${message}. Run in another terminal: ${command}`, "error");
92
+ }
93
+ },
94
+ });
95
+
96
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
97
+ let widgetContext: ExtensionContext | undefined;
98
+ let refreshing = false;
99
+
100
+ // The widget does not start the host: when none runs, it shows nothing.
101
+ const refreshWidget = async (): Promise<void> => {
102
+ if (!widgetContext || refreshing) return;
103
+ refreshing = true;
104
+
105
+ try {
106
+ const context = widgetContext;
107
+
108
+ const children = await request(
109
+ { type: "list", caller: context.sessionManager.getSessionId() },
110
+ { start: false },
111
+ ).catch(() => undefined);
112
+
113
+ if (context !== widgetContext) return;
114
+
115
+ if (children === undefined || children.length === 0) {
116
+ context.ui.setWidget("subagents", undefined);
117
+
118
+ return;
119
+ }
120
+
121
+ const theme = context.ui.theme;
122
+
123
+ const visible = children
124
+ .slice(0, 5)
125
+ .map(({ name, handle, state }: { name?: string; handle: string; state: string }) => {
126
+ const color = state === "busy" ? "warning" : state === "idle" ? "success" : "error";
127
+
128
+ return theme.fg(color, `${name ?? handle}:${state}`);
129
+ });
130
+
131
+ if (children.length > visible.length) visible.push(theme.fg("muted", `+${children.length - visible.length}`));
132
+ context.ui.setWidget("subagents", [theme.fg("dim", "subagents: ") + visible.join(theme.fg("dim", " | "))], {
133
+ placement: "belowEditor",
134
+ });
135
+ } finally {
136
+ refreshing = false;
137
+ }
138
+ };
139
+
140
+ pi.on("session_start", (_event, ctx) => {
141
+ if (!ctx.hasUI) return;
142
+ widgetContext = ctx;
143
+ void refreshWidget();
144
+ widgetTimer = setInterval(() => void refreshWidget(), 1000);
145
+ widgetTimer.unref();
146
+ });
147
+
148
+ pi.on("session_shutdown", (_event, ctx) => {
149
+ if (widgetTimer) clearInterval(widgetTimer);
150
+ widgetTimer = undefined;
151
+ widgetContext = undefined;
152
+ ctx.ui.setWidget("subagents", undefined);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Whether Pi trusts the project in `cwd` with no one to ask, as the host does in `src/trust.ts` of harness. It is here
158
+ * with Pi's own modules, so Pi does not load the copy of pi-coding-agent that harness installs.
159
+ */
160
+ function savedTrust(cwd: string): boolean {
161
+ if (!hasTrustRequiringProjectResources(cwd)) return true;
162
+ const agentDir = getAgentDir();
163
+ const decision = new ProjectTrustStore(agentDir).get(cwd);
164
+
165
+ if (decision !== null) return decision;
166
+
167
+ return SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
168
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "private": true,
3
+ "description": "The Pi extension of harness: the subagent tool, the /subagent command and the subagents widget",
4
+ "type": "module",
5
+ "pi": {
6
+ "extensions": [
7
+ "./index.ts"
8
+ ]
9
+ },
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-ai": "*",
12
+ "@earendil-works/pi-coding-agent": "*"
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ // Registers `pi/` in Pi after a global install, with `setup` of `dist/setup.js`. Plain JavaScript, since Node does not
2
+ // strip types under node_modules. Other installs, such as `npm ci` in a checkout without `dist/`, do nothing, and an
3
+ // error must not fail the npm install.
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ if (process.env.npm_config_global === "true") {
7
+ try {
8
+ const { setup } = await import("../dist/setup.js");
9
+ setup(fileURLToPath(new URL("..", import.meta.url)));
10
+ } catch (error) {
11
+ console.warn(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}`);
12
+ }
13
+ }