@yunazgr/pi-companion 0.3.2 → 0.3.4
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/README.md +9 -3
- package/package.json +1 -1
- package/src/ask.ts +5 -4
- package/src/bridge.ts +15 -2
- package/src/popup.ts +83 -0
- package/src/protocol.ts +10 -0
- package/src/telemetry.ts +38 -0
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Lightweight, local-first remote control for Pi sessions.
|
|
4
4
|
|
|
5
|
-
**New in 0.3.
|
|
5
|
+
**New in 0.3.4:** Custom Pi terminal popups now appear in Companion with a claymorphism shell, scrollable terminal view, adjustable text size, and touch-friendly navigation and input controls. Interact with custom slash-command screens or question pickers from either device; native question sheets still work for standard dialogs and `jar_ask`.
|
|
6
|
+
|
|
7
|
+
**New in 0.3.2:** responsive automation and run-history tables, rounded status chips, matching clay-icon headers, and a single-column automation workspace with full-width mobile controls. **Edit automation** scrolls to and focuses the inline editor. Pull to refresh Overview, Sessions, Automations, or Settings—with a gesture; unsaved settings stay intact. Camera-policy guidance now explains HTTPS proxy configuration. Existing rich results, staged job editing, optional retries, and answers-only automation sessions are retained. See [automations](docs/automations.md).
|
|
6
8
|
|
|
7
9
|
Pi Companion is deliberately not another agent runtime. Pi owns execution and conversation state. A single Rust daemon owns session discovery, pairing, temporary file exchange, browser fan-out, and the embedded web UI.
|
|
8
10
|
|
|
@@ -149,7 +151,11 @@ A paired device cannot access sessions that have not explicitly enabled remote c
|
|
|
149
151
|
|
|
150
152
|
Pi's `companion_ask_user` tool asks one to four questions at once. Each question can offer options with descriptions, allow several choices (`multiSelect`), and accept a free-text "Other" answer; a question without options is free text. The browser shows them in a bottom sheet within thumb reach; "Later" hides it until you tap the waiting-question badge.
|
|
151
153
|
|
|
152
|
-
Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker
|
|
154
|
+
Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker can use a native-sheet adapter: pi-jar's `jar_ask` is supported, and a companion answer completes its terminal picker. Standard dialogs work for any extension, not just `jar_ask`. Pending questions are part of the session snapshot, so a browser that connects later still sees them.
|
|
155
|
+
|
|
156
|
+
Other `ctx.ui.custom` components are mirrored in a terminal-style popup, including custom slash-command screens. ANSI colors and selection highlighting are preserved inside a clay-styled, vertically and horizontally scrollable viewer. Text-size controls and view-scroll buttons make long or wide screens easier to read; separate navigation controls and expandable extra keys operate the original component. The input bar stays available while the popup body scrolls, and unsent text is retained if the connection drops. Enter submits according to the extension's keybindings; Close sends Escape so the component supplies its own cancellation result. Components that ignore Escape may remain open. Local completion closes the companion view, and pending frames survive browser reconnects. Ending sharing stops mirroring without closing the local component.
|
|
157
|
+
|
|
158
|
+
Custom popups use the local terminal's rendered width (scroll horizontally on narrow screens), not an independently resized terminal. Mouse-only interactions, terminal images, and direct terminal I/O are not mirrored. RPC automation sessions cannot mirror custom terminal components because Pi does not create them in RPC mode. `ctx.ui.editor` remains terminal-only.
|
|
153
159
|
|
|
154
160
|
## Refreshing your workspace
|
|
155
161
|
|
|
@@ -192,7 +198,7 @@ Network loss and daemon restarts reconnect automatically with bounded backoff. H
|
|
|
192
198
|
|
|
193
199
|
Shared session title, model, effort and working directory refresh on Pi events and once per second while sharing, including while idle. Session detail shows context-window usage and estimated session cost. Native Pi context and recorded usage costs take precedence; independent extension reports fill unavailable fields. Missing usage is shown as unavailable, not zero.
|
|
194
200
|
|
|
195
|
-
**Settings → Usage** is available on the console and paired devices. It displays the latest snapshot per provider: weekly usage, 5-hour usage when reported, reset times, source and snapshot age. Quota snapshots are not added across sessions or accounts.
|
|
201
|
+
**Settings → Usage** is available on the console and paired devices. It displays the latest snapshot per provider: weekly usage, 5-hour usage when reported, reset times, source and snapshot age. Quota snapshots are not added across sessions or accounts. Those limits are extension-reported, not billing totals or locally inferred quotas. Companion additionally reports actual session token consumption and cost grouped by the model's provider from Pi's recorded assistant messages; this is session-only data, not an account-wide tally. Settings selects the newest consumption independently of quota freshness and displays their timestamps and session-ended indicators separately. When no quota adapter is installed, 5-hour and weekly quotas still show as unavailable rather than fabricated percentages.
|
|
196
202
|
|
|
197
203
|
Extensions can publish a provider-neutral event without importing Companion:
|
|
198
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/ask.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AskAnswers, AskOption, AskQuestion, AskRequest } from "./protocol.js";
|
|
3
|
+
import type { PopupRelay } from "./popup.js";
|
|
3
4
|
|
|
4
5
|
export type AskInput = Omit<AskRequest, "requestId" | "createdAt">;
|
|
5
6
|
|
|
@@ -16,10 +17,10 @@ const RELAYED = Symbol.for("pi-companion.dialog-relay");
|
|
|
16
17
|
*
|
|
17
18
|
* Pi hands every extension the same ui object, so patching it once per object covers all of
|
|
18
19
|
* them. The terminal dialog stays open; whichever side answers first wins and the other is
|
|
19
|
-
* dismissed. `editor` takes no AbortSignal, so it stays terminal-only.
|
|
20
|
-
*
|
|
20
|
+
* dismissed. `editor` takes no AbortSignal, so it stays terminal-only. Known custom question
|
|
21
|
+
* tools use native sheets (ToolDialogRelay); other custom components use terminal mirroring.
|
|
21
22
|
*/
|
|
22
|
-
export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools: ToolDialogRelay) {
|
|
23
|
+
export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools: ToolDialogRelay, popups?: PopupRelay) {
|
|
23
24
|
const target = ui as ExtensionUIContext & { [RELAYED]?: true };
|
|
24
25
|
if (target[RELAYED]) return;
|
|
25
26
|
target[RELAYED] = true;
|
|
@@ -32,7 +33,7 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
|
|
|
32
33
|
|
|
33
34
|
ui.custom = (factory, options) => {
|
|
34
35
|
const complete = tools.claim();
|
|
35
|
-
if (!complete) return custom(factory, options);
|
|
36
|
+
if (!complete) return custom(popups ? popups.wrap(factory) : factory, options);
|
|
36
37
|
return custom((tui, theme, keybindings, done) => {
|
|
37
38
|
complete((result) => done(result as never));
|
|
38
39
|
return factory(tui, theme, keybindings, done);
|
package/src/bridge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
6
6
|
import { relayDialogs, ToolDialogRelay, type AskChannel, type AskInput } from "./ask.js";
|
|
7
7
|
import { ensureDaemon } from "./daemon.js";
|
|
8
8
|
import { TelemetryRelay } from "./telemetry.js";
|
|
9
|
+
import { PopupRelay } from "./popup.js";
|
|
9
10
|
import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnapshot, TempFile } from "./protocol.js";
|
|
10
11
|
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -14,6 +15,10 @@ export class CompanionBridge implements AskChannel {
|
|
|
14
15
|
readonly sessionId = randomUUID();
|
|
15
16
|
/** Relays question tools that ask through ctx.ui.custom (fed from tool_execution_* events). */
|
|
16
17
|
readonly toolDialogs = new ToolDialogRelay(this);
|
|
18
|
+
readonly popups = new PopupRelay(popups => {
|
|
19
|
+
this.snapshot.popups = popups;
|
|
20
|
+
this.send({ type: "session.update", session: { popups } });
|
|
21
|
+
});
|
|
17
22
|
private ws?: WebSocket;
|
|
18
23
|
private ctx?: ExtensionContext;
|
|
19
24
|
private reconnect?: NodeJS.Timeout;
|
|
@@ -48,7 +53,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
48
53
|
|
|
49
54
|
setContext(ctx: ExtensionContext) {
|
|
50
55
|
this.ctx = ctx;
|
|
51
|
-
if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
|
|
56
|
+
if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs, this.popups);
|
|
52
57
|
this.refreshMetadata();
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -115,6 +120,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
115
120
|
// End only Companion sharing: Pi itself and its local history keep running.
|
|
116
121
|
this.restoreDialogs?.();
|
|
117
122
|
this.restoreDialogs = undefined;
|
|
123
|
+
this.popups.clear();
|
|
118
124
|
this.restoreStatus?.();
|
|
119
125
|
this.restoreStatus = undefined;
|
|
120
126
|
if (this.metadataTimer) clearInterval(this.metadataTimer);
|
|
@@ -144,7 +150,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
144
150
|
/** Only an explicit /companion command may activate this session's bridge. */
|
|
145
151
|
activate() {
|
|
146
152
|
this.activated = true;
|
|
147
|
-
if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
|
|
153
|
+
if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs, this.popups);
|
|
148
154
|
if (!this.snapshot.remoteEnabled) return;
|
|
149
155
|
const ui = this.ctx?.ui;
|
|
150
156
|
if (ui && typeof ui.setStatus === "function" && !this.restoreStatus) {
|
|
@@ -233,6 +239,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
233
239
|
this.closed = true;
|
|
234
240
|
this.restoreDialogs?.();
|
|
235
241
|
this.restoreDialogs = undefined;
|
|
242
|
+
this.popups.clear();
|
|
236
243
|
this.restoreStatus?.();
|
|
237
244
|
this.restoreStatus = undefined;
|
|
238
245
|
if (this.metadataTimer) clearInterval(this.metadataTimer);
|
|
@@ -335,6 +342,12 @@ export class CompanionBridge implements AskChannel {
|
|
|
335
342
|
case "git_diff":
|
|
336
343
|
await this.sendGitDiff(Boolean(command.staged));
|
|
337
344
|
break;
|
|
345
|
+
case "popup_input":
|
|
346
|
+
this.popups.input(command.popupId, command.data);
|
|
347
|
+
break;
|
|
348
|
+
case "popup_close":
|
|
349
|
+
this.popups.close(command.popupId);
|
|
350
|
+
break;
|
|
338
351
|
case "ask_answer":
|
|
339
352
|
this.asks.get(command.requestId)?.settle(command.answers ?? {});
|
|
340
353
|
break;
|
package/src/popup.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TerminalPopup } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
type CustomFactory = Parameters<ExtensionUIContext["custom"]>[0];
|
|
6
|
+
type FactoryArgs = Parameters<CustomFactory>;
|
|
7
|
+
type PopupFactory<T> = (tui: FactoryArgs[0], theme: FactoryArgs[1], keys: FactoryArgs[2], done: (result: T) => void) => ReturnType<CustomFactory>;
|
|
8
|
+
|
|
9
|
+
/** Mirrors rendered terminal lines, never serializes or evaluates extension code in the browser. */
|
|
10
|
+
export class PopupRelay {
|
|
11
|
+
private frameTimer?: NodeJS.Timeout;
|
|
12
|
+
private generation = 0;
|
|
13
|
+
private active = new Map<string, { frame: TerminalPopup; input: (data: string) => void; close: () => void }>();
|
|
14
|
+
constructor(private publish: (popups: TerminalPopup[]) => void) {}
|
|
15
|
+
|
|
16
|
+
wrap<T>(factory: PopupFactory<T>) {
|
|
17
|
+
return async (...args: Parameters<typeof factory>) => {
|
|
18
|
+
const [tui, theme, keys, done] = args;
|
|
19
|
+
const generation = this.generation;
|
|
20
|
+
const id = randomUUID();
|
|
21
|
+
let finished = false;
|
|
22
|
+
const remove = () => {
|
|
23
|
+
finished = true;
|
|
24
|
+
if (this.active.delete(id)) this.flush();
|
|
25
|
+
};
|
|
26
|
+
const finish = (result: T) => { if (finished) return; remove(); done(result); };
|
|
27
|
+
const component = await factory(tui, theme, keys, finish);
|
|
28
|
+
if (finished || generation !== this.generation) return component;
|
|
29
|
+
const entry = {
|
|
30
|
+
frame: { id, lines: [], width: 0 } as TerminalPopup,
|
|
31
|
+
input: (data: string) => { component.handleInput?.(data); tui.requestRender(); },
|
|
32
|
+
// Let the component produce its own cancellation result; arbitrary factories
|
|
33
|
+
// may require a tagged result rather than undefined.
|
|
34
|
+
close: () => { component.handleInput?.("\x1b"); tui.requestRender(); }
|
|
35
|
+
};
|
|
36
|
+
this.active.set(id, entry);
|
|
37
|
+
// Patch the instance rather than proxying it: preserve focus, cursor and mouse behavior.
|
|
38
|
+
const render = component.render;
|
|
39
|
+
const dispose = component.dispose;
|
|
40
|
+
component.render = function(width) {
|
|
41
|
+
const lines = render.call(this, width);
|
|
42
|
+
if (!finished && relay.active.has(id)) {
|
|
43
|
+
let budget = 128_000;
|
|
44
|
+
const bounded: string[] = [];
|
|
45
|
+
for (const line of lines.slice(0, 500)) {
|
|
46
|
+
const text = line.slice(0, Math.min(16384, budget));
|
|
47
|
+
bounded.push(text);
|
|
48
|
+
budget -= text.length;
|
|
49
|
+
if (budget <= 0) break;
|
|
50
|
+
}
|
|
51
|
+
const frame = { id, width, lines: bounded };
|
|
52
|
+
if (JSON.stringify(frame) !== JSON.stringify(entry.frame)) {
|
|
53
|
+
const firstFrame = entry.frame.width === 0;
|
|
54
|
+
entry.frame = frame;
|
|
55
|
+
if (firstFrame) relay.flush();
|
|
56
|
+
else relay.scheduleFrame();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
};
|
|
61
|
+
const relay = this;
|
|
62
|
+
component.dispose = function() { remove(); dispose?.call(this); };
|
|
63
|
+
return component;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
input(id: string, data: unknown) {
|
|
68
|
+
if (typeof data === "string" && data.length > 0 && data.length <= 4096) this.active.get(id)?.input(data);
|
|
69
|
+
}
|
|
70
|
+
close(id: string) { this.active.get(id)?.close(); }
|
|
71
|
+
/** Stop mirroring without terminating the user's local dialog. */
|
|
72
|
+
clear() { this.generation++; if (!this.active.size) return; this.active.clear(); this.flush(); }
|
|
73
|
+
private scheduleFrame() {
|
|
74
|
+
if (this.frameTimer) return;
|
|
75
|
+
this.frameTimer = setTimeout(() => this.flush(), 50);
|
|
76
|
+
this.frameTimer.unref();
|
|
77
|
+
}
|
|
78
|
+
private flush() {
|
|
79
|
+
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
80
|
+
this.frameTimer = undefined;
|
|
81
|
+
this.publish([...this.active.values()].map(entry => entry.frame));
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -43,6 +43,11 @@ export type ProviderUsage = {
|
|
|
43
43
|
updatedAt: string;
|
|
44
44
|
weekly?: UsageWindow;
|
|
45
45
|
fiveHour?: UsageWindow;
|
|
46
|
+
/** Observed consumption in this shared Pi session, not account quota utilization. */
|
|
47
|
+
sessionTokens?: number;
|
|
48
|
+
sessionCost?: number;
|
|
49
|
+
/** Native consumption freshness, independent of the account quota report. */
|
|
50
|
+
sessionUpdatedAt?: string;
|
|
46
51
|
};
|
|
47
52
|
export type SessionTelemetry = {
|
|
48
53
|
context?: { tokens?: number; window?: number; percent?: number; source?: string };
|
|
@@ -50,7 +55,10 @@ export type SessionTelemetry = {
|
|
|
50
55
|
providers?: ProviderUsage[];
|
|
51
56
|
};
|
|
52
57
|
|
|
58
|
+
export type TerminalPopup = { id: string; lines: string[]; width: number };
|
|
59
|
+
|
|
53
60
|
export type SessionSnapshot = {
|
|
61
|
+
popups?: TerminalPopup[];
|
|
54
62
|
id: string;
|
|
55
63
|
name?: string | null;
|
|
56
64
|
cwd: string;
|
|
@@ -68,6 +76,8 @@ export type SessionSnapshot = {
|
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
export type BrowserCommand =
|
|
79
|
+
| { type: "popup_input"; popupId: string; data: string }
|
|
80
|
+
| { type: "popup_close"; popupId: string }
|
|
71
81
|
| { type: "prompt"; text: string }
|
|
72
82
|
| { type: "steer"; text: string }
|
|
73
83
|
| { type: "abort" }
|
package/src/telemetry.ts
CHANGED
|
@@ -51,6 +51,7 @@ export function normalizeTelemetry(value: unknown, source = "extension", now = D
|
|
|
51
51
|
/** Independent sources may supply different fields; one broken/missing adapter cannot erase another. */
|
|
52
52
|
export class TelemetryRelay {
|
|
53
53
|
private sources = new Map<string, { telemetry: SessionTelemetry; contextAt?: number; costAt?: number }>();
|
|
54
|
+
private nativeUsage = new Map<string, { fingerprint: string; updatedAt: string }>();
|
|
54
55
|
|
|
55
56
|
ingest(value: unknown, fallbackSource = "extension", now = Date.now()) {
|
|
56
57
|
const raw = record(value);
|
|
@@ -140,6 +141,43 @@ export class TelemetryRelay {
|
|
|
140
141
|
}
|
|
141
142
|
if (known && Number.isFinite(total)) result.cost = { amount: total, currency: "USD", source: "native" };
|
|
142
143
|
} catch { /* Preserve extension estimate when native usage is unavailable. */ }
|
|
144
|
+
// Native session entries are available without another plugin. They report actual
|
|
145
|
+
// consumption, not provider subscription limits, and must never fabricate quotas.
|
|
146
|
+
try {
|
|
147
|
+
const totals = new Map<string, { tokens: number; cost: number; hasTokens: boolean; hasCost: boolean }>();
|
|
148
|
+
for (const entry of ctx?.sessionManager?.getEntries?.() ?? []) {
|
|
149
|
+
const raw = record(entry);
|
|
150
|
+
if (raw?.type !== "message") continue;
|
|
151
|
+
const message = record(raw.message);
|
|
152
|
+
if (message?.role !== "assistant") continue;
|
|
153
|
+
const provider = text(message.provider);
|
|
154
|
+
const usage = record(message.usage);
|
|
155
|
+
if (!provider || !usage) continue;
|
|
156
|
+
const pieces = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite].map(number);
|
|
157
|
+
const tokens = number(usage.totalTokens) ?? (pieces.some(value => value !== undefined)
|
|
158
|
+
? pieces.reduce<number>((sum, value) => sum + (value ?? 0), 0) : undefined);
|
|
159
|
+
const cost = number(record(usage.cost)?.total);
|
|
160
|
+
if (tokens === undefined && cost === undefined) continue;
|
|
161
|
+
const total = totals.get(provider) ?? { tokens: 0, cost: 0, hasTokens: false, hasCost: false };
|
|
162
|
+
if (tokens !== undefined) { total.tokens += tokens; total.hasTokens = true; }
|
|
163
|
+
if (cost !== undefined) { total.cost += cost; total.hasCost = true; }
|
|
164
|
+
totals.set(provider, total);
|
|
165
|
+
}
|
|
166
|
+
for (const [provider, total] of totals) {
|
|
167
|
+
const fingerprint = JSON.stringify(total);
|
|
168
|
+
const cached = this.nativeUsage.get(provider);
|
|
169
|
+
const updatedAt = cached?.fingerprint === fingerprint ? cached.updatedAt : new Date(now).toISOString();
|
|
170
|
+
this.nativeUsage.set(provider, { fingerprint, updatedAt });
|
|
171
|
+
const old = providers.get(provider);
|
|
172
|
+
providers.set(provider, {
|
|
173
|
+
...old, provider, source: old?.source ?? "Pi session", updatedAt: old?.updatedAt ?? updatedAt,
|
|
174
|
+
sessionUpdatedAt: updatedAt,
|
|
175
|
+
...(total.hasTokens ? { sessionTokens: total.tokens } : {}),
|
|
176
|
+
...(total.hasCost ? { sessionCost: total.cost } : {})
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (providers.size) result.providers = [...providers.values()];
|
|
180
|
+
} catch { /* Keep independently reported quota snapshots if native entries are unavailable. */ }
|
|
143
181
|
return result;
|
|
144
182
|
}
|
|
145
183
|
}
|