@jameslovespancakes/pi-plus 1.0.18 → 1.0.20

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,183 @@
1
+ import { basename } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ClaudeRemoteBridge, type BridgeOptions } from "../../core/claude-remote/bridge.ts";
4
+ import { mirrorMessage } from "../../core/claude-remote/protocol.ts";
5
+ import { env, setEnv } from "../../core/env.ts";
6
+ import { createTokenSource } from "./auth.ts";
7
+ import { remoteControlPicker } from "./picker.ts";
8
+
9
+ interface Bridge {
10
+ start(): Promise<void>;
11
+ stop(): void;
12
+ send: ClaudeRemoteBridge["send"];
13
+ reportState: ClaudeRemoteBridge["reportState"];
14
+ }
15
+
16
+ /** Dependency seams allow lifecycle tests without network access or real credentials. */
17
+ export interface RemoteDependencies {
18
+ bridge(options: BridgeOptions): Bridge;
19
+ tokenSource(): BridgeOptions["getAccessToken"];
20
+ }
21
+
22
+ const defaults: RemoteDependencies = {
23
+ bridge: (options) => new ClaudeRemoteBridge(options),
24
+ tokenSource: () => createTokenSource(),
25
+ };
26
+
27
+ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies = defaults): void {
28
+ let active: Bridge | undefined;
29
+ let status = "off";
30
+ let enabled = false;
31
+ let current: ExtensionContext | undefined;
32
+ let generation = 0;
33
+ // Counts rather than a TTL: follow-ups can wait longer than 30 seconds.
34
+ const echoes: string[] = [];
35
+
36
+ function notify(ctx: ExtensionContext, message: string, warning = false): void {
37
+ ctx.ui.notify(message, warning ? "warning" : "info");
38
+ }
39
+
40
+ function setConnectionStatus(value: string): void {
41
+ status = value;
42
+ if (!current?.hasUI) return;
43
+ const connected = value.startsWith("connected");
44
+ const label = connected ? (value.includes("read-only") ? "Read-only" : "Active")
45
+ : value === "connecting" ? "Connecting" : "Offline";
46
+ current.ui.setStatus("claude-remote",
47
+ current.ui.theme.fg(connected ? "success" : "error", `● Remote Control ${label}`));
48
+ }
49
+
50
+ function stop(): void {
51
+ ++generation;
52
+ active?.stop();
53
+ active = undefined;
54
+ echoes.length = 0;
55
+ setConnectionStatus("off");
56
+ }
57
+
58
+ function start(ctx: ExtensionContext): void {
59
+ if (active) {
60
+ notify(ctx, `Claude Remote: ${status}. Open https://claude.ai/code`);
61
+ return;
62
+ }
63
+ current = ctx;
64
+ const gen = ++generation;
65
+ const title = `pi: ${pi.getSessionName() || basename(ctx.cwd) || "session"}`.slice(0, 100);
66
+ setConnectionStatus("connecting");
67
+ try {
68
+ const allowInbound = !/^(0|false|off|no)$/i.test(env("PI_CLAUDE_REMOTE_ALLOW_INBOUND") ?? "1");
69
+ const bridge = deps.bridge({
70
+ title, allowInbound,
71
+ trustedDeviceToken: env("CLAUDE_TRUSTED_DEVICE_TOKEN"),
72
+ getAccessToken: deps.tokenSource(),
73
+ onText(text) {
74
+ if (generation !== gen || !current || !allowInbound) return;
75
+ if (echoes.length >= 256) throw new Error("Too many remote follow-ups");
76
+ echoes.push(text);
77
+ try {
78
+ pi.sendUserMessage(text, current.isIdle() ? undefined : { deliverAs: "followUp" });
79
+ } catch (error) {
80
+ echoes.splice(echoes.lastIndexOf(text), 1);
81
+ throw error;
82
+ }
83
+ },
84
+ onInterrupt() {
85
+ if (generation === gen && allowInbound) current?.abort();
86
+ },
87
+ onConnect(id) {
88
+ if (generation !== gen) return;
89
+ setConnectionStatus(allowInbound ? "connected" : "connected (read-only)");
90
+ notify(ctx, `Claude Remote: ${title} is live at https://claude.ai/code (${id}).`);
91
+ },
92
+ onConnectionChange(connected) {
93
+ if (generation !== gen) return;
94
+ setConnectionStatus(connected ? (allowInbound ? "connected" : "connected (read-only)") : "connecting");
95
+ },
96
+ onError(message) {
97
+ if (generation !== gen) return;
98
+ stop();
99
+ setConnectionStatus("disconnected");
100
+ notify(ctx, `${message}. Pi continues locally.`, true);
101
+ },
102
+ });
103
+ active = bridge;
104
+ bridge.reportState(ctx.isIdle() ? "idle" : "running");
105
+ void bridge.start().catch(() => {
106
+ if (generation !== gen) return;
107
+ stop();
108
+ setConnectionStatus("disconnected");
109
+ notify(ctx, "Claude Remote could not connect. Run /login for Anthropic, then /claude-remote on.", true);
110
+ });
111
+ } catch {
112
+ stop();
113
+ notify(ctx, "Claude Remote needs an Anthropic OAuth login. Use /login, then /claude-remote on.", true);
114
+ }
115
+ }
116
+
117
+ function setEnabled(next: boolean, ctx: ExtensionContext): boolean {
118
+ enabled = next;
119
+ const saved = setEnv("PI_CLAUDE_REMOTE", next ? "1" : "0");
120
+ if (next) start(ctx);
121
+ else stop();
122
+ if (!saved) notify(ctx, "Could not save preference; changed this session only.", true);
123
+ else if ((env("PI_CLAUDE_REMOTE") === "1") !== next) {
124
+ notify(ctx, "PI_CLAUDE_REMOTE overrides this preference after reload.", true);
125
+ }
126
+ return enabled;
127
+ }
128
+
129
+ pi.registerCommand("claude-remote", {
130
+ description: "Remote Control on/off",
131
+ getArgumentCompletions: (prefix) => ["on", "off"]
132
+ .filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
133
+ handler: async (args, ctx) => {
134
+ const action = args.trim().toLowerCase();
135
+ if (!action && ctx.mode === "tui") {
136
+ await ctx.ui.custom((_tui, theme, _keys, done) => remoteControlPicker(
137
+ theme, enabled, (next) => setEnabled(next, ctx), () => done(undefined),
138
+ ));
139
+ } else if (action === "on" || action === "off") {
140
+ if (action === "on" && ctx.hasUI && !enabled && !await ctx.ui.confirm("Enable Remote Control?",
141
+ "Share sessions with Anthropic and control pi from the Claude app. Auto-starts in interactive sessions.")) return;
142
+ setEnabled(action === "on", ctx);
143
+ } else notify(ctx, "Usage: /claude-remote [on|off]", true);
144
+ },
145
+ });
146
+
147
+ pi.on("session_start", (_event, ctx) => {
148
+ stop();
149
+ current = ctx;
150
+ setConnectionStatus("off");
151
+ enabled = env("PI_CLAUDE_REMOTE") === "1";
152
+ // Never spawn remote mirrors for workflow/SDK/print subagents by default.
153
+ if (ctx.mode === "tui" && enabled) start(ctx);
154
+ });
155
+ pi.on("session_shutdown", (_event, ctx) => {
156
+ stop();
157
+ if (ctx.hasUI) ctx.ui.setStatus("claude-remote", undefined);
158
+ current = undefined;
159
+ });
160
+ pi.on("session_tree", (_event, ctx) => {
161
+ const wasActive = !!active;
162
+ stop();
163
+ current = ctx;
164
+ if (wasActive) start(ctx); // an old branch's remote input must not control a new branch
165
+ });
166
+ pi.on("message_end", (event, ctx) => {
167
+ current = ctx;
168
+ if (!active) return;
169
+ const message = event.message;
170
+ if (message.role === "user") {
171
+ const text = typeof message.content === "string" ? message.content
172
+ : message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
173
+ const index = echoes.indexOf(text);
174
+ if (index !== -1) { echoes.splice(index, 1); return; }
175
+ }
176
+ const outbound = mirrorMessage(message);
177
+ if (outbound) active.send(outbound);
178
+ });
179
+ pi.on("agent_start", (_event, ctx) => { current = ctx; active?.reportState("running"); });
180
+ pi.on("agent_end", (_event, ctx) => { current = ctx; active?.reportState("idle"); });
181
+ }
182
+
183
+ export default function claudeRemote(pi: ExtensionAPI): void { registerClaudeRemote(pi); }
@@ -0,0 +1,36 @@
1
+ import { SettingsList, type Component } from "@earendil-works/pi-tui";
2
+ import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+ import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
4
+
5
+ /** Same dot, colors and in-place SettingsList toggle as /provider. */
6
+ export function remoteControlPicker(
7
+ theme: any,
8
+ initial: boolean,
9
+ toggle: (enabled: boolean) => boolean,
10
+ done: () => void,
11
+ ): Component {
12
+ let enabled = initial;
13
+ const color = (value: boolean, text: string) => hasTruecolor()
14
+ ? levelColor(value ? 100 : 0)(text) : theme.fg(value ? "success" : "error", text);
15
+ const label = () => `${color(enabled, "●")} Remote Control`;
16
+ const value = () => color(enabled, enabled ? "On" : "Off");
17
+ const item = {
18
+ id: "remote-control", label: label(), currentValue: value(),
19
+ values: [color(true, "On"), color(false, "Off")],
20
+ };
21
+ const list = new SettingsList([item], 1, settingsTheme(theme), () => {
22
+ enabled = toggle(!enabled);
23
+ item.label = label();
24
+ list.updateValue(item.id, value());
25
+ }, done, { enableSearch: false });
26
+ const frame = frameSettings(theme, list, "Remote Control");
27
+ return {
28
+ ...frame,
29
+ invalidate() {
30
+ item.values = [color(true, "On"), color(false, "Off")];
31
+ item.label = label();
32
+ list.updateValue(item.id, value());
33
+ frame.invalidate();
34
+ },
35
+ };
36
+ }
@@ -14,7 +14,7 @@ import {
14
14
  } from "../../core/catalog/quality.ts";
15
15
  import { ensureFresh, usageState } from "../../services/usage-service.ts";
16
16
  import { geminiQuotaFamily } from "../../core/gemini/quota.ts";
17
- import { combinedWindow, isGeminiAccount, pooledWindow } from "../../core/quota/pool.ts";
17
+ import { combinedWindow, isGeminiAccount, pooledWindow, rollOver } from "../../core/quota/pool.ts";
18
18
  import { env, isFromProcessEnv, maskSecret, setEnv } from "../../core/env.ts";
19
19
  import { fitId } from "../../ui/format.ts";
20
20
 
@@ -57,7 +57,7 @@ interface Entry {
57
57
 
58
58
  function quotaFor(provider: string, modelId: string): number | undefined {
59
59
  const state = usageState();
60
- const rows = state.rows;
60
+ const rows = rollOver(state.rows);
61
61
  if (provider === "anthropic") {
62
62
  // The pool's figure, not whichever account happens to be listed first.
63
63
  const pool = combinedWindow(rows, "5h", state.accounts, Date.now(), true);
@@ -1,5 +1,6 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
2
  import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+ import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
3
4
 
4
5
  /**
5
6
  * The `/provider` picker.
@@ -79,50 +80,6 @@ function labelFor(theme: any, row: ProviderRow): string {
79
80
  return `${dot} ${name}`;
80
81
  }
81
82
 
82
- /**
83
- * The chrome pi uses for `/model` and its other in-chat pickers.
84
- *
85
- * Not a box: a full-width accent rule, a bold title, the body, then a closing
86
- * rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
87
- * this reads as part of the chat flow rather than as a floating dialog.
88
- *
89
- * Input and mouse events pass straight through, so the frame is presentation
90
- * only and does not disturb the in-place updates.
91
- */
92
- function framed(theme: any, list: any, title: string): Component {
93
- const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
94
-
95
- return {
96
- invalidate: () => list.invalidate?.(),
97
- handleInput: (data: string) => list.handleInput(data),
98
- handleMouse: (event: any) => list.handleMouse?.(event),
99
- render(width: number): string[] {
100
- const inner = Math.max(1, width);
101
- // pi pads title and footer by one column; the list renders flush.
102
- return [
103
- rule(inner),
104
- ` ${theme.fg("accent", theme.bold(title))}`,
105
- ...list.render(inner),
106
- rule(inner),
107
- ];
108
- },
109
- } as Component;
110
- }
111
-
112
- /**
113
- * Theme for the picker. Every callback takes `selected` so the highlighted row
114
- * can be emphasised without the caller tracking cursor position.
115
- */
116
- function pickerTheme(theme: any) {
117
- return {
118
- label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
119
- value: (text: string, _selected: boolean) => text,
120
- description: (text: string) => theme.fg("dim", text),
121
- cursor: theme.fg("accent", "›"),
122
- hint: (text: string) => theme.fg("dim", text),
123
- };
124
- }
125
-
126
83
  export interface PickerDeps {
127
84
  /** Re-reads the current rows, so the picker never shows stale state. */
128
85
  rows: () => Promise<ProviderRow[]>;
@@ -195,13 +152,13 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
195
152
  list = new SettingsList(
196
153
  items,
197
154
  12,
198
- pickerTheme(theme),
155
+ settingsTheme(theme),
199
156
  onChange,
200
157
  () => done(undefined),
201
158
  { enableSearch: false },
202
159
  );
203
160
 
204
- return framed(theme, list, "Providers") as Component & { dispose?(): void };
161
+ return frameSettings(theme, list, "Providers") as Component & { dispose?(): void };
205
162
  });
206
163
  // No `overlay` option: the picker renders inline in the chat flow rather than
207
164
  // floating over it.
@@ -131,6 +131,17 @@ async function inspect(ctx: any): Promise<Feature[]> {
131
131
  open: "/remote setup",
132
132
  });
133
133
 
134
+ /* Claude app remote control (preference only; never connect from the hub). */
135
+ const claudeRemote = env("PI_CLAUDE_REMOTE") === "1";
136
+ features.push({
137
+ name: "Claude Remote",
138
+ ready: claudeRemote,
139
+ detail: claudeRemote ? "auto-start enabled for interactive sessions" : "opt-in Claude app mirror; requires Anthropic OAuth",
140
+ commands: ["/claude-remote", "/claude-remote on", "/claude-remote off"],
141
+ setup: "/claude-remote",
142
+ open: "/claude-remote",
143
+ });
144
+
134
145
  return features;
135
146
  }
136
147
 
@@ -166,7 +177,7 @@ function buildBrief(features: Feature[]): string {
166
177
  `Config file: ${configPath()}${existsSync(configPath()) ? "" : " (not created yet)"}`,
167
178
  "",
168
179
  "Write the reply yourself, in chat. Requirements:",
169
- "1. One short sentence on what pi-plus is: four capabilities in one package.",
180
+ "1. One short sentence on what pi-plus is: extensions for subscriptions, models, workflows, and remote collaboration.",
170
181
  "2. A compact list of the capabilities, each with one line on what it does and the command to try. Mark which are already working.",
171
182
  pending.length > 0
172
183
  ? `3. Then a short 'Set these up next' section covering ONLY the ones marked NOT SET UP (${pending.map((f) => f.name).join(", ")}), each with the single command to run and one line on what it will ask for.`
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { WorkflowProgressEvent } from "./types.ts";
3
3
  import type { AgentChatMessage, AgentChatRole, AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
4
- import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./usage.ts";
4
+ import type { WorkflowUsageSnapshot } from "./usage.ts";
5
5
  import { unknownErrorMessage } from "./unknown-error.ts";
6
- import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
6
+ import type { WorkflowStatusCounts } from "./ui/workflow-format.ts";
7
7
  import { toDisplayLine, toDisplayText } from "./ui/display-text.ts";
8
8
  import { renderWorkflowWidgetLines } from "./ui/workflow-widget.ts";
9
9
 
@@ -61,7 +61,7 @@ const WIDGET_REFRESH_INTERVAL_MS = 1_000;
61
61
  export const DEFAULT_LANE_ITEM_LIMIT = 200;
62
62
 
63
63
  /**
64
- * Tracks live workflow state for widgets, footer/status text, result renderers,
64
+ * Tracks live workflow state for widgets, result renderers,
65
65
  * and headless stderr breadcrumbs.
66
66
  */
67
67
  export class ProgressTracker {
@@ -81,17 +81,24 @@ export class ProgressTracker {
81
81
  private doneAt: number | undefined;
82
82
  private currentPhase = "Workflow";
83
83
  private nextAgentId = 1;
84
- private lastStatusText: string | undefined;
85
84
  private usageSnapshot: WorkflowUsageSnapshot | undefined;
86
85
  private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
87
86
  private readonly surfaceKey: string;
87
+ private readonly ctx: ExtensionContext;
88
+ private readonly title: string;
89
+ private readonly runId: string;
90
+ private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
88
91
 
89
92
  constructor(
90
- private readonly ctx: ExtensionContext,
91
- private readonly title: string,
92
- private readonly runId: string,
93
- private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
93
+ ctx: ExtensionContext,
94
+ title: string,
95
+ runId: string,
96
+ onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
94
97
  ) {
98
+ this.ctx = ctx;
99
+ this.title = title;
100
+ this.runId = runId;
101
+ this.onSnapshot = onSnapshot;
95
102
  this.surfaceKey = `workflow:${runId}`;
96
103
  this.ensurePhase(this.currentPhase);
97
104
  }
@@ -336,7 +343,6 @@ export class ProgressTracker {
336
343
  if (!this.ctx.hasUI) return;
337
344
  this.publishWidget();
338
345
  this.startWidgetRefresh();
339
- this.publishStatus();
340
346
  }
341
347
 
342
348
  private publishWidget(): void {
@@ -358,24 +364,6 @@ export class ProgressTracker {
358
364
  this.widgetRefreshInterval = undefined;
359
365
  }
360
366
 
361
- private publishStatus(): void {
362
- const status = statusTextFromCounts(
363
- {
364
- title: this.title,
365
- doneAt: this.doneAt,
366
- currentPhase: this.currentPhase,
367
- counters: [...this.counters.values()].map((counter) => ({ ...counter })),
368
- },
369
- this.statusCountsSnapshot(),
370
- this.ctx.ui.theme,
371
- );
372
- const usage = formatWorkflowUsageLine(this.usageSnapshot);
373
- const next = usage ? `${status} · ${usage}` : status;
374
- if (next === this.lastStatusText) return;
375
- this.ctx.ui.setStatus(this.surfaceKey, next);
376
- this.lastStatusText = next;
377
- }
378
-
379
367
  /** Clear this run's live workflow surfaces. Final feedback is delivered by the result surface. */
380
368
  done(): void {
381
369
  this.doneAt = Date.now();
@@ -384,7 +372,6 @@ export class ProgressTracker {
384
372
  if (!this.ctx.hasUI) return;
385
373
  this.ctx.ui.setWidget(this.surfaceKey, undefined);
386
374
  this.ctx.ui.setStatus(this.surfaceKey, undefined);
387
- this.lastStatusText = undefined;
388
375
  }
389
376
 
390
377
  private publishSnapshot(): void {
@@ -0,0 +1,26 @@
1
+ import { truncateToWidth, type Component, type SettingsListTheme } from "@earendil-works/pi-tui";
2
+
3
+ /** Shared inline chrome for provider-style SettingsList pickers. */
4
+ export function frameSettings(theme: any, list: Component, title: string): Component {
5
+ const rule = (width: number) => theme.fg("accent", "─".repeat(width));
6
+ return {
7
+ invalidate: () => list.invalidate(),
8
+ handleInput: (data) => list.handleInput?.(data),
9
+ handleMouse: (event) => list.handleMouse?.(event),
10
+ render(width) {
11
+ const inner = Math.max(1, width);
12
+ return [rule(inner), truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, inner, ""),
13
+ ...list.render(inner), rule(inner)];
14
+ },
15
+ };
16
+ }
17
+
18
+ export function settingsTheme(theme: any): SettingsListTheme {
19
+ return {
20
+ label: (text, selected) => selected ? theme.fg("accent", text) : text,
21
+ value: (text) => text,
22
+ description: (text) => theme.fg("dim", text),
23
+ cursor: theme.fg("accent", "›"),
24
+ hint: (text) => theme.fg("dim", text),
25
+ };
26
+ }
@@ -8,6 +8,7 @@ import {
8
8
  OBSERVED_PROVIDERS,
9
9
  poolAvailability,
10
10
  pooledWindow,
11
+ rollOver,
11
12
  scopedLabels,
12
13
  type UsageRow,
13
14
  } from "../core/quota/pool.ts";
@@ -163,7 +164,9 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
163
164
  const label = theme.fg(cell.active ? "accent" : "muted", cell.label.slice(0, labelWidth).padEnd(labelWidth));
164
165
  const reset = formatShortReset(cell.resetAt);
165
166
  const resetWidth = 4;
166
- const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
167
+ // label, space, bar, space, 5-wide percent, space, reset: exactly cellWidth,
168
+ // so every row's right column starts under its title.
169
+ const barWidth = Math.max(4, cellWidth - labelWidth - resetWidth - 8);
167
170
 
168
171
  if (cell.remaining === undefined) {
169
172
  return `${label} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
@@ -188,7 +191,8 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
188
191
  return `${label} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
189
192
  }
190
193
 
191
- export function renderUsageLines(state: UsageState, theme: any, width: number, active?: ActiveModel): string[] {
194
+ export function renderUsageLines(raw: UsageState, theme: any, width: number, active?: ActiveModel): string[] {
195
+ const state = { ...raw, rows: rollOver(raw.rows) };
192
196
  if (state.loading) return [theme.fg("dim", " usage: loading…")];
193
197
  if (state.rows.length === 0) {
194
198
  if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
@@ -219,7 +223,8 @@ export function renderUsageLines(state: UsageState, theme: any, width: number, a
219
223
  return lines.map((line) => truncateToWidth(line, width, ""));
220
224
  }
221
225
 
222
- export function usageSummaryText(state: UsageState): string {
226
+ export function usageSummaryText(raw: UsageState): string {
227
+ const state = { ...raw, rows: rollOver(raw.rows) };
223
228
  const combined = ["5h", "7d", ...scopedLabels(state.rows)].map((label) => {
224
229
  const pool = combinedWindow(state.rows, label, state.accounts, Date.now(), true);
225
230
  return `Claude combined ${label}: ${pool