pim-agent 0.10.0 → 0.12.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.
Files changed (41) hide show
  1. package/package.json +3 -4
  2. package/packages/core/src/extensions/apply-patch/model.ts +1 -12
  3. package/packages/core/src/extensions/bash/index.ts +2 -1
  4. package/packages/core/src/extensions/bash/run.ts +8 -2
  5. package/packages/core/src/extensions/edit/index.ts +1 -1
  6. package/packages/core/src/extensions/subagent/index.ts +2 -1
  7. package/packages/core/src/extensions/subagent/subagent.ts +10 -1
  8. package/packages/core/src/extensions/system-prompt/index.ts +33 -24
  9. package/packages/core/src/extensions/system-prompt/prompt.ts +43 -67
  10. package/packages/core/src/session/SessionHost.ts +35 -9
  11. package/packages/core/src/session/SessionRegistry.ts +22 -1
  12. package/packages/core/src/session/SessionUi.ts +127 -0
  13. package/packages/core/src/shared/Directories.ts +39 -5
  14. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  15. package/packages/core/src/shared/PiExtensions.ts +228 -0
  16. package/packages/core/src/shared/Tools.ts +3 -3
  17. package/packages/protocol/src/Command.ts +22 -1
  18. package/packages/protocol/src/ServerEvent.ts +34 -0
  19. package/packages/server/src/ProbeClient.ts +21 -0
  20. package/packages/server/src/SessionStream.ts +228 -2
  21. package/packages/server/src/WsGateway.ts +153 -16
  22. package/packages/server/src/probe.ts +16 -0
  23. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  24. package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-DKI-ZG3V.js} +1 -1
  25. package/packages/web/dist/client/assets/index-BCSESkyh.css +1 -0
  26. package/packages/web/dist/client/assets/index-BWUZzdeu.js +45 -0
  27. package/packages/web/dist/client/index.html +2 -2
  28. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  29. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  30. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  31. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  32. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  33. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  34. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  35. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  36. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  37. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  38. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  39. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  40. package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
  41. package/packages/web/dist/client/assets/index-CpinM5NK.js +0 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pim-agent",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "A batteries-included distro of Pi, accessible in your terminal, browser and Telegram.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,7 +56,6 @@
56
56
  "web:dev": "cd packages/web && vite"
57
57
  },
58
58
  "devDependencies": {
59
- "@fontsource/commit-mono": "^5.3.0",
60
59
  "@happy-dom/global-registrator": "^20.14.0",
61
60
  "@iconify-json/griddy-icons": "^1.2.0",
62
61
  "@iconify-json/solar": "^1.2.10",
@@ -76,8 +75,8 @@
76
75
  "vite": "^8.2.2"
77
76
  },
78
77
  "dependencies": {
79
- "@earendil-works/pi-coding-agent": "0.85.1",
80
- "@earendil-works/pi-server": "0.85.1",
78
+ "@earendil-works/pi-coding-agent": "0.87.1",
79
+ "@earendil-works/pi-server": "0.87.1",
81
80
  "diff": "^9.0.0",
82
81
  "fzf": "^0.5.2",
83
82
  "grammy": "^1.46.0",
@@ -4,8 +4,6 @@ type ModelLike = {
4
4
  readonly id?: string;
5
5
  };
6
6
 
7
- const CLAUDE_APPLY_PATCH_MAJOR = 5;
8
-
9
7
  // Never match a GPT-named non-OpenAI model (`eleutherai/gpt-neo`): a false positive hands it a tool it was not trained on.
10
8
  function isGptModel(model: ModelLike | undefined): boolean {
11
9
  const provider = (model?.provider ?? "").toLowerCase();
@@ -25,15 +23,6 @@ function isGptModel(model: ModelLike | undefined): boolean {
25
23
  );
26
24
  }
27
25
 
28
- function claudeMajorVersion(id: string): number | undefined {
29
- const match = /(?:^|[/.])claude-[a-z]+-(\d+)/.exec(id);
30
- return match ? Number(match[1]) : undefined;
31
- }
32
-
33
26
  export function prefersApplyPatch(model: ModelLike | undefined): boolean {
34
- if (isGptModel(model)) {
35
- return true;
36
- }
37
- const major = claudeMajorVersion((model?.id ?? "").toLowerCase());
38
- return major !== undefined && major >= CLAUDE_APPLY_PATCH_MAJOR;
27
+ return isGptModel(model);
39
28
  }
@@ -42,7 +42,8 @@ export default function (pi: ExtensionAPI): void {
42
42
  name: "bash",
43
43
  label: "bash",
44
44
  description:
45
- "Execute a bash command in the cwd. " +
45
+ "Execute a bash command in the cwd from <environment>. " +
46
+ "Never prefix `cd <cwd> &&`; pass paths relative to cwd or absolute. " +
46
47
  "Returns exit code, signal (if any), and stdout/stderr captured separately. " +
47
48
  "Stdout that is a png/jpeg/gif/webp is returned as a picture. " +
48
49
  "Prefer commands that emit only what you need; keep output as small as possible.",
@@ -14,6 +14,12 @@ type Reader = ReadableStreamDefaultReader<Uint8Array>;
14
14
 
15
15
  const activePids = new Set<number>();
16
16
 
17
+ const COMMAND_VAR = "PIM_BASH_COMMAND";
18
+ // argv is what `pkill -f`/`pgrep -f` match, so a command naming its own target would
19
+ // signal this shell before its target. The env hands it over out of argv's reach, and
20
+ // the unset keeps it out of every child's environment too.
21
+ const RUNNER = `__pim_command=$${COMMAND_VAR}; unset ${COMMAND_VAR}; eval "$__pim_command"`;
22
+
17
23
  export function killAllActiveBashGroups(sig: NodeJS.Signals = "SIGTERM"): void {
18
24
  for (const pid of activePids) {
19
25
  killGroup(pid, sig);
@@ -97,11 +103,11 @@ export async function runBashCommand(
97
103
 
98
104
  // setsid gives the tree its own process group (pgid == proc.pid) so the whole tree can be signalled.
99
105
  const proc = Bun.spawn({
100
- cmd: ["setsid", "bash", "-lc", command],
106
+ cmd: ["setsid", "bash", "-lc", RUNNER],
101
107
  cwd,
102
108
  stdout: "pipe",
103
109
  stderr: "pipe",
104
- env: { ...process.env },
110
+ env: { ...process.env, [COMMAND_VAR]: command },
105
111
  });
106
112
  if (proc.pid !== undefined) {
107
113
  activePids.add(proc.pid);
@@ -10,7 +10,7 @@ export default function (pi: ExtensionAPI): void {
10
10
  name: "edit",
11
11
  label: "edit",
12
12
  description:
13
- "Replace strings in a UTF-8 text file. " +
13
+ "Replace strings in a UTF-8 text file; batch several replacements in one call via `edits`. " +
14
14
  "Prefer edit over write for changes to existing files.",
15
15
  parameters: editSchema,
16
16
  renderShell: "self",
@@ -12,7 +12,7 @@ export default function (pi: ExtensionAPI): void {
12
12
  label: "subagent",
13
13
  description:
14
14
  "Run a task in an isolated subagent with a fresh context. " +
15
- "The subagent inherits the currently active tools, except subagent itself. " +
15
+ "The subagent inherits the current model, thinking level and active tools, except subagent itself. " +
16
16
  "Multiple subagent calls in one turn run in parallel. " +
17
17
  "Subagent output returned to the main agent is capped at 32KB.",
18
18
  parameters: subagentSchema,
@@ -26,6 +26,7 @@ export default function (pi: ExtensionAPI): void {
26
26
  signal,
27
27
  onUpdate,
28
28
  activeToolNames: pi.getActiveTools(),
29
+ thinkingLevel: pi.getThinkingLevel(),
29
30
  });
30
31
  },
31
32
  toViewModel: subagentView,
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
3
  import type {
3
4
  AgentSessionEvent,
4
5
  AgentToolResult,
@@ -66,6 +67,8 @@ export type SubagentSession = {
66
67
 
67
68
  export type SubagentSessionSpec = {
68
69
  readonly activeToolNames?: readonly string[];
70
+ /** The parent's live level; left unset, pi falls back to settings, not to the parent. */
71
+ readonly thinkingLevel?: ThinkingLevel;
69
72
  /** The parent's tool call id, which names the child's log on disk. */
70
73
  readonly callId?: string;
71
74
  };
@@ -111,6 +114,7 @@ export async function createSdkSubagentSession(
111
114
  cwd: parentCtx.cwd,
112
115
  agentDir: getAgentDir(),
113
116
  model: parentCtx.model,
117
+ thinkingLevel: spec.thinkingLevel,
114
118
  sessionManager: await childSessionManager(parentCtx, spec.callId),
115
119
  resourceLoader: loader,
116
120
  tools: spec.activeToolNames
@@ -144,6 +148,7 @@ export async function runSubagent(
144
148
  onUpdate,
145
149
  createSession = createSdkSubagentSession,
146
150
  activeToolNames,
151
+ thinkingLevel,
147
152
  callId,
148
153
  } = run;
149
154
 
@@ -175,7 +180,11 @@ export async function runSubagent(
175
180
  };
176
181
 
177
182
  try {
178
- session = await createSession(parentCtx, { activeToolNames, callId });
183
+ session = await createSession(parentCtx, {
184
+ activeToolNames,
185
+ thinkingLevel,
186
+ callId,
187
+ });
179
188
  capture.noteSessionId(session.sessionId);
180
189
  session.subscribe((event) => capture.handle(event));
181
190
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -2,35 +2,44 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionFactory,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
6
-
7
5
  import type { Surface } from "../../shared/Surface";
8
- import { buildSystemPrompt } from "./prompt";
6
+ import {
7
+ buildEnvironment,
8
+ buildInstructions,
9
+ formatDatetime,
10
+ leadWithSystemPrompt,
11
+ } from "./prompt";
12
+
13
+ const DATETIME_MESSAGE_TYPE = "pim-datetime";
9
14
 
10
15
  export default function (surface?: Surface): ExtensionFactory {
11
16
  return (pi: ExtensionAPI): void => {
17
+ // On submit rather than in `before_agent_start`, which can only append
18
+ // behind the user's message.
19
+ pi.on("input", () => {
20
+ pi.sendMessage(
21
+ {
22
+ customType: DATETIME_MESSAGE_TYPE,
23
+ content: `<datetime>${formatDatetime(new Date())}</datetime>`,
24
+ display: false,
25
+ },
26
+ { triggerTurn: false }
27
+ );
28
+ return { action: "continue" };
29
+ });
12
30
  pi.on("before_agent_start", (event, ctx) => {
13
- const {
14
- cwd,
15
- contextFiles,
16
- skills,
17
- promptGuidelines,
18
- appendSystemPrompt,
19
- customPrompt,
20
- } = event.systemPromptOptions;
21
- return {
22
- systemPrompt: buildSystemPrompt({
23
- model: ctx.model,
24
- cwd,
25
- contextFiles: contextFiles ?? [],
26
- skillsBlock:
27
- skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
28
- toolGuidelines: promptGuidelines ?? [],
29
- appendSystemPrompt,
30
- customPrompt,
31
- ...(surface === undefined ? {} : { surface }),
32
- }),
33
- };
31
+ const options = event.systemPromptOptions;
32
+ if (!options.customPrompt?.trim()) {
33
+ options.customPrompt = buildInstructions(options);
34
+ }
35
+ options.sections.environment = buildEnvironment({
36
+ model: ctx.model,
37
+ surface,
38
+ });
39
+ });
40
+ pi.on("context_with_system", (event) => {
41
+ const messages = leadWithSystemPrompt(event.messages);
42
+ return messages ? { messages } : undefined;
34
43
  });
35
44
  };
36
45
  }
@@ -1,18 +1,14 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { NormalizedBuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { Surface } from "../../shared/Surface";
4
4
 
5
- type BuildOptions = {
6
- readonly model?: ExtensionContext["model"];
7
- readonly cwd: string;
8
- readonly contextFiles: ReadonlyArray<{
9
- readonly path: string;
10
- readonly content: string;
11
- }>;
12
- readonly skillsBlock: string;
13
- readonly toolGuidelines: ReadonlyArray<string>;
14
- readonly appendSystemPrompt?: string;
15
- readonly customPrompt?: string;
5
+ type GuidelineOptions = Pick<
6
+ NormalizedBuildSystemPromptOptions,
7
+ "selectedTools" | "toolGuidelines" | "promptGuidelines"
8
+ >;
9
+
10
+ type EnvironmentOptions = {
11
+ readonly model?: { readonly id: string; readonly provider: string };
16
12
  readonly os?: string;
17
13
  readonly surface?: Surface;
18
14
  };
@@ -34,57 +30,45 @@ function dynamicGuidelines(): ReadonlyArray<string> {
34
30
  return guidelines;
35
31
  }
36
32
 
37
- export function buildSystemPrompt(opts: BuildOptions): string {
38
- const sections: string[] = [];
39
-
40
- if (opts.customPrompt && opts.customPrompt.trim().length > 0) {
41
- sections.push(opts.customPrompt);
42
- } else {
43
- sections.push(
44
- [
45
- "<system_instructions>",
46
- "You are Pim (Pi IMproved), a batteries-included agent built on the Pi harness.",
47
- ...opts.toolGuidelines.map((g) => `- ${g}`),
48
- ...dynamicGuidelines().map((g) => `- ${g}`),
49
- "</system_instructions>",
50
- ].join("\n")
51
- );
33
+ export function buildInstructions(options: GuidelineOptions): string {
34
+ const guidelines = new Set([
35
+ ...options.selectedTools.flatMap(
36
+ (name) => options.toolGuidelines[name] ?? []
37
+ ),
38
+ ...options.promptGuidelines,
39
+ ...dynamicGuidelines(),
40
+ ]);
41
+ return [
42
+ "<system_instructions>",
43
+ "You are Pim (Pi IMproved), a batteries-included agent built on the Pi harness.",
44
+ ...[...guidelines].map((g) => `- ${g}`),
45
+ "</system_instructions>",
46
+ ].join("\n");
47
+ }
48
+
49
+ export function leadWithSystemPrompt<T extends { readonly role: string }>(
50
+ messages: ReadonlyArray<T>
51
+ ): T[] | undefined {
52
+ const first = messages.findIndex((message) => message.role === "system");
53
+ if (first <= 0) {
54
+ return undefined;
52
55
  }
56
+ return [
57
+ messages[first]!,
58
+ ...messages.slice(0, first),
59
+ ...messages.slice(first + 1),
60
+ ];
61
+ }
53
62
 
63
+ export function buildEnvironment(opts: EnvironmentOptions): string {
54
64
  const model = opts.model
55
65
  ? `${opts.model.id} via ${opts.model.provider}`
56
66
  : "unknown";
57
- sections.push(
58
- [
59
- "<environment>",
60
- `- cwd: ${opts.cwd}`,
61
- `- os: ${opts.os ?? describeOs()}`,
62
- `- model: ${model}`,
63
- ...(opts.surface ? [`- surface: ${opts.surface}`] : []),
64
- `- datetime: ${formatDatetime(new Date())}`,
65
- "</environment>",
66
- ].join("\n")
67
- );
68
-
69
- if (opts.contextFiles.length > 0) {
70
- const files = opts.contextFiles
71
- .map(
72
- ({ path, content }) =>
73
- `<file path="${escapeXmlAttr(path)}">\n${content}\n</file>`
74
- )
75
- .join("\n");
76
- sections.push(`<project_instructions>\n${files}\n</project_instructions>`);
77
- }
78
-
79
- if (opts.skillsBlock) {
80
- sections.push(opts.skillsBlock.trimStart());
81
- }
82
-
83
- if (opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0) {
84
- sections.push(opts.appendSystemPrompt);
85
- }
86
-
87
- return sections.join("\n\n");
67
+ return [
68
+ `- os: ${opts.os ?? describeOs()}`,
69
+ `- model: ${model}`,
70
+ ...(opts.surface ? [`- surface: ${opts.surface}`] : []),
71
+ ].join("\n");
88
72
  }
89
73
 
90
74
  export function describeOs(options: OsDescriptionOptions = {}): string {
@@ -210,7 +194,7 @@ function unquoteValue(value: string): string {
210
194
  return quote === "'" ? unquoted : unquoted.replace(/\\(["\\$`])/g, "$1");
211
195
  }
212
196
 
213
- function formatDatetime(d: Date): string {
197
+ export function formatDatetime(d: Date): string {
214
198
  const pad = (n: number): string => String(n).padStart(2, "0");
215
199
  const offsetMinutes = -d.getTimezoneOffset();
216
200
  const sign = offsetMinutes >= 0 ? "+" : "-";
@@ -222,11 +206,3 @@ function formatDatetime(d: Date): string {
222
206
  const day = d.toLocaleDateString("en-US", { weekday: "long" });
223
207
  return `${iso} (${day})`;
224
208
  }
225
-
226
- function escapeXmlAttr(value: string): string {
227
- return value
228
- .replace(/&/g, "&amp;")
229
- .replace(/"/g, "&quot;")
230
- .replace(/</g, "&lt;")
231
- .replace(/>/g, "&gt;");
232
- }
@@ -15,6 +15,7 @@ import {
15
15
  SettingsManager,
16
16
  type AgentSessionEvent,
17
17
  type CompactionResult,
18
+ type ExtensionError,
18
19
  type ToolDefinition,
19
20
  } from "@earendil-works/pi-coding-agent";
20
21
  import { unlink } from "node:fs/promises";
@@ -30,6 +31,7 @@ import {
30
31
  type LeaseRecord,
31
32
  } from "./SessionLease";
32
33
  import { SessionName } from "./SessionName";
34
+ import { adaptSessionUi, type SessionUi } from "./SessionUi";
33
35
  import { WriteMark } from "./WriteMark";
34
36
 
35
37
  /** What the agent is doing right now. */
@@ -102,6 +104,13 @@ export type SessionHostDeps = {
102
104
  ) => readonly ToolDefinition[];
103
105
  /** Runs after the agent is disposed, before the session file is forgotten. */
104
106
  readonly onRetire?: (sessionPath: string) => Promise<void>;
107
+ /**
108
+ * Where this host's extensions say things, resolved per call so an agent
109
+ * rebuilt under the host follows its live target. Omit to leave pi's no-op
110
+ * UI in place: pi freezes the mode at `bindExtensions`, so this is a dep
111
+ * rather than something a caller can turn on once a session exists.
112
+ */
113
+ readonly ui?: () => SessionUi | undefined;
105
114
  };
106
115
 
107
116
  /** What a frontend's own tools are built against. */
@@ -770,16 +779,33 @@ export class SessionHost {
770
779
  ],
771
780
  });
772
781
 
773
- // Emits session_start; without it extension tools are registered but never usable.
774
- await agent.bindExtensions({
775
- mode: "print",
776
- onError: (err) => {
777
- console.warn(
778
- `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
779
- err.error
782
+ const onError = (err: ExtensionError) => {
783
+ console.warn(
784
+ `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
785
+ err.error
786
+ );
787
+ // Pi answers a command handler that threw as if it had succeeded, so
788
+ // without this the user's message vanishes and only stderr says why.
789
+ this.deps
790
+ .ui?.()
791
+ ?.notify(
792
+ `${err.extensionPath} (${err.event}) failed: ${err.error}`,
793
+ "error"
780
794
  );
781
- },
782
- });
795
+ };
796
+ const ui = this.deps.ui;
797
+ // Emits session_start; without it extension tools are registered but never usable.
798
+ await agent.bindExtensions(
799
+ ui
800
+ ? // Any context at all makes pi's `hasUI()` true (`uiContext !== noOpUIContext`,
801
+ // runner.js:318); "rpc" is its own name for dialog-capable but not a terminal.
802
+ {
803
+ uiContext: adaptSessionUi(ui),
804
+ mode: "rpc",
805
+ onError,
806
+ }
807
+ : { mode: "print", onError }
808
+ );
783
809
 
784
810
  return { agent, cwd };
785
811
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "./SessionHost";
19
19
  import { SessionLease } from "./SessionLease";
20
20
  import { SessionName } from "./SessionName";
21
+ import type { SessionUi } from "./SessionUi";
21
22
 
22
23
  /** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
23
24
  export type SessionSummary = {
@@ -58,6 +59,7 @@ export class SessionRegistry {
58
59
  private readonly deps: SessionRegistryDeps;
59
60
  private readonly runtime: AgentRuntime;
60
61
  private readonly hosts: SessionCache<SessionHost>;
62
+ private ui: ((host: SessionHost) => SessionUi | undefined) | undefined;
61
63
 
62
64
  public constructor(deps: SessionRegistryDeps) {
63
65
  this.deps = deps;
@@ -65,10 +67,24 @@ export class SessionRegistry {
65
67
  this.hosts = new SessionCache(deps.capacity);
66
68
  }
67
69
 
70
+ /**
71
+ * Where the extensions of every host built from here speak. It belongs to the
72
+ * registry rather than each caller because `create` binds an agent eagerly,
73
+ * and pi freezes a session's UI mode at that bind. Resolved per call: a
74
+ * host outlives the stream that speaks for it.
75
+ */
76
+ public setUi(ui: (host: SessionHost) => SessionUi | undefined): void {
77
+ this.ui = ui;
78
+ }
79
+
68
80
  public get sessionsRoot(): string {
69
81
  return join(this.runtime.agentDir, "sessions");
70
82
  }
71
83
 
84
+ public get agentDir(): string {
85
+ return this.runtime.agentDir;
86
+ }
87
+
72
88
  public async init(): Promise<void> {
73
89
  await this.runtime.init();
74
90
  }
@@ -207,7 +223,10 @@ export class SessionRegistry {
207
223
  }
208
224
 
209
225
  private buildHost(label: string, settings: HostSettings): SessionHost {
210
- return new SessionHost({
226
+ const ui = this.ui;
227
+ // Named so the sink can reach back for the host it belongs to; the closure
228
+ // only ever runs once construction has returned.
229
+ const host: SessionHost = new SessionHost({
211
230
  label: `session ${label}`,
212
231
  settings,
213
232
  defaults: this.deps.defaults,
@@ -225,7 +244,9 @@ export class SessionRegistry {
225
244
  ...(this.deps.surface === undefined
226
245
  ? {}
227
246
  : { surface: this.deps.surface }),
247
+ ...(ui === undefined ? {} : { ui: () => ui(host) }),
228
248
  });
249
+ return host;
229
250
  }
230
251
  }
231
252
 
@@ -0,0 +1,127 @@
1
+ import type {
2
+ ExtensionUIContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { NoticeSeverity } from "../view/ViewBlock";
7
+
8
+ /** The slice of pi's dialog options a surface without a terminal can honour; the rest is countdown chrome. */
9
+ export type UiAsk = {
10
+ readonly timeout?: number;
11
+ readonly signal?: AbortSignal;
12
+ };
13
+
14
+ /** Where an extension's own words go. Four of pi's thirty methods: the ones that address a human rather than a terminal. */
15
+ export type SessionUi = {
16
+ readonly notify: (text: string, severity: NoticeSeverity) => void;
17
+ readonly select: (
18
+ title: string,
19
+ options: readonly string[],
20
+ opts?: UiAsk
21
+ ) => Promise<string | undefined>;
22
+ readonly confirm: (
23
+ title: string,
24
+ message: string,
25
+ opts?: UiAsk
26
+ ) => Promise<boolean>;
27
+ readonly input: (
28
+ title: string,
29
+ placeholder?: string,
30
+ opts?: UiAsk
31
+ ) => Promise<string | undefined>;
32
+ };
33
+
34
+ /** Everything off the terminal paints with; also what pi's border getters hand back. */
35
+ const IDENTITY = (text: string): string => text;
36
+
37
+ /**
38
+ * pi keeps its own `theme` instance to itself, and nothing off the terminal has
39
+ * colours to paint with. `satisfies` covers the whole public surface — `keyof`
40
+ * skips the private fields the cast is for — so a method pi adds is a type
41
+ * error here rather than a throw inside somebody's event handler.
42
+ */
43
+ const PLAIN_THEME = {
44
+ fg: (_color: string, text: string) => text,
45
+ bg: (_color: string, text: string) => text,
46
+ bold: IDENTITY,
47
+ italic: IDENTITY,
48
+ underline: IDENTITY,
49
+ inverse: IDENTITY,
50
+ strikethrough: IDENTITY,
51
+ getFgAnsi: () => "",
52
+ getBgAnsi: () => "",
53
+ getColorMode: () => "truecolor",
54
+ getThinkingBorderColor: () => IDENTITY,
55
+ getBashModeBorderColor: () => IDENTITY,
56
+ } satisfies { [K in keyof Theme]: Theme[K] } as unknown as Theme;
57
+
58
+ function severityOf(
59
+ kind: Parameters<ExtensionUIContext["notify"]>[1]
60
+ ): NoticeSeverity {
61
+ return kind === "warning" ? "warn" : (kind ?? "info");
62
+ }
63
+
64
+ /** A dialog an extension awaits goes unanswered rather than ever throwing at it. */
65
+ async function settle<T>(
66
+ ask: () => Promise<T> | undefined
67
+ ): Promise<T | undefined> {
68
+ try {
69
+ return await ask();
70
+ } catch (err) {
71
+ console.warn("[SessionUi] dialog sink failed:", err);
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Widens the sink onto pi's `ExtensionUIContext`, no-opping everything a
78
+ * terminal owns exactly as pi's own RPC mode does. `sink` is read per call, so
79
+ * an agent rebuilt under a host keeps whatever its host was last handed.
80
+ *
81
+ * Every method has to be an *own* property: pi re-wraps what it is given with
82
+ * `{ ...ui }` (`runner.js:274`), which copies own enumerable keys only. A class
83
+ * instance would arrive with `notify` and the rest of the prototype missing.
84
+ */
85
+ export function adaptSessionUi(
86
+ sink: () => SessionUi | undefined
87
+ ): ExtensionUIContext {
88
+ return {
89
+ select: (title, options, opts) =>
90
+ settle(() => sink()?.select(title, options, opts)),
91
+ confirm: async (title, message, opts) =>
92
+ (await settle(() => sink()?.confirm(title, message, opts))) === true,
93
+ input: (title, placeholder, opts) =>
94
+ settle(() => sink()?.input(title, placeholder, opts)),
95
+ notify: (message, kind) => {
96
+ try {
97
+ sink()?.notify(message, severityOf(kind));
98
+ } catch (err) {
99
+ console.warn("[SessionUi] notify sink failed:", err);
100
+ }
101
+ },
102
+ onTerminalInput: () => () => {},
103
+ setStatus: () => {},
104
+ setWorkingMessage: () => {},
105
+ setWorkingVisible: () => {},
106
+ setWorkingIndicator: () => {},
107
+ setHiddenThinkingLabel: () => {},
108
+ setWidget: () => {},
109
+ setFooter: () => {},
110
+ setHeader: () => {},
111
+ setTitle: () => {},
112
+ custom: async <T>() => undefined as T,
113
+ pasteToEditor: () => {},
114
+ setEditorText: () => {},
115
+ getEditorText: () => "",
116
+ editor: async () => undefined,
117
+ addAutocompleteProvider: () => {},
118
+ setEditorComponent: () => {},
119
+ getEditorComponent: () => undefined,
120
+ theme: PLAIN_THEME,
121
+ getAllThemes: () => [],
122
+ getTheme: () => undefined,
123
+ setTheme: () => ({ success: false, error: "UI not available" }),
124
+ getToolsExpanded: () => false,
125
+ setToolsExpanded: () => {},
126
+ };
127
+ }