@yunazgr/pi-companion 0.3.6 → 0.3.7
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 +5 -3
- package/package.json +1 -1
- package/src/bridge.ts +30 -5
- package/src/index.ts +18 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Lightweight, local-first remote control for Pi sessions.
|
|
4
4
|
|
|
5
|
-
**New in 0.3.
|
|
5
|
+
**New in 0.3.7:** Resuming a previously shared Pi session reconnects automatically under the same dashboard identity. Explicit `/remote-control off` survives resume; new and forked sessions stay private. Unnamed session titles now reflect the first user prompt, refresh with branch context, and remain editable with `/name`.
|
|
6
6
|
|
|
7
7
|
When a session is shared, Companion fetches OpenAI Codex and Anthropic subscription usage via Pi's existing OAuth resolver, independently per provider with five-minute caching and failure backoff. Credentials are never sent to the browser or stored by the daemon. API-key accounts and unsupported providers have no built-in subscription lookup; extensions can still publish the neutral telemetry contract. Provider rate-limit, quota and authentication failures are shown in the activity feed with safe remediation guidance rather than raw provider payloads.
|
|
8
8
|
|
|
@@ -192,7 +192,7 @@ Each paired device shows a live **Connected** / **Disconnected** status (with th
|
|
|
192
192
|
|
|
193
193
|
Paired devices (name, browser user agent, pairing and last-seen times, credential hash) and settings survive daemon restarts. They are stored in `~/.pi/agent/pi-companion/state.json`, created with mode 0600 inside a 0700 directory and replaced atomically; a looser mode found on startup is tightened. Override the directory with `PI_COMPANION_HOME`.
|
|
194
194
|
|
|
195
|
-
A phone pairs once with the daemon. Individual Pi sessions opt in using `/remote-control`.
|
|
195
|
+
A phone pairs once with the daemon. Individual Pi sessions opt in using `/remote-control`. This choice is saved in Pi session history: resuming that same session restores sharing unless you used `/remote-control off`. New sessions and forks do not inherit another session's opt-in. After upgrading from 0.3.6 or earlier, enable sharing once to save this choice; older versions did not record it.
|
|
196
196
|
|
|
197
197
|
Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect. Recent activity is replayed from the daemon’s bounded log, but prompts/answers are never automatically resent. A deliberate **Disconnect** waits for the device’s explicit retry; **Revoke** removes access and requires pairing again.
|
|
198
198
|
|
|
@@ -294,6 +294,8 @@ A previously cached PWA shell can also explain gateway failures on reload. If th
|
|
|
294
294
|
|
|
295
295
|
## Session identity
|
|
296
296
|
|
|
297
|
+
Companion uses Pi's persistent session ID, so restarting, resuming, or re-enabling sharing replaces the same dashboard entry instead of creating a duplicate. Unnamed sessions use the first text-bearing user prompt in the current branch as their title (whitespace-normalized and capped at 120 characters), with the project folder as a fallback before a prompt exists. `/name` overrides that title and updates the dashboard live; clearing the name restores the prompt-derived title.
|
|
298
|
+
|
|
297
299
|
Every registered Pi session publishes a compact display model to the daemon:
|
|
298
300
|
|
|
299
301
|
- session name
|
|
@@ -304,7 +306,7 @@ Every registered Pi session publishes a compact display model to the daemon:
|
|
|
304
306
|
- working directory and process id
|
|
305
307
|
- remote-control state
|
|
306
308
|
|
|
307
|
-
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period. Use **Archive session** on the list or in the session menu to remove a disconnected/stopped entry. The daemon rejects removal of active, idle, waiting, or otherwise connected sessions. Archiving removes only the daemon entry; it never deletes Pi history, project files or other files on disk. Temporary uploads still follow the existing disconnect cleanup lifecycle. Reopening
|
|
309
|
+
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period. Use **Archive session** on the list or in the session menu to remove a disconnected/stopped entry. The daemon rejects removal of active, idle, waiting, or otherwise connected sessions. Archiving removes only the daemon entry; it never deletes Pi history, project files or other files on disk. Temporary uploads still follow the existing disconnect cleanup lifecycle. Reopening a previously shared Pi session registers it again automatically; otherwise use `/companion`. Entries created by older versions with random IDs can be archived once disconnected.
|
|
308
310
|
|
|
309
311
|
## UI
|
|
310
312
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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/bridge.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnaps
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
14
14
|
export class CompanionBridge implements AskChannel {
|
|
15
|
-
|
|
15
|
+
private pendingPrompt?: string;
|
|
16
16
|
/** Relays question tools that ask through ctx.ui.custom (fed from tool_execution_* events). */
|
|
17
17
|
readonly toolDialogs = new ToolDialogRelay(this);
|
|
18
18
|
readonly popups = new PopupRelay(popups => {
|
|
@@ -39,7 +39,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
39
39
|
private asks = new Map<string, { request: AskRequest; settle: (answers: AskAnswers | null) => void }>();
|
|
40
40
|
private pendingDeletes = new Map<string, { resolve: (result: { ok: boolean; error?: string }) => void; timer: NodeJS.Timeout }>();
|
|
41
41
|
private snapshot: SessionSnapshot = {
|
|
42
|
-
id:
|
|
42
|
+
id: "",
|
|
43
43
|
cwd: process.cwd(),
|
|
44
44
|
pid: process.pid,
|
|
45
45
|
shortTitle: process.cwd().split(/[\\/]/).filter(Boolean).pop() ?? "Pi",
|
|
@@ -49,7 +49,9 @@ export class CompanionBridge implements AskChannel {
|
|
|
49
49
|
asks: []
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
constructor(private readonly pi: ExtensionAPI) {
|
|
52
|
+
constructor(private readonly pi: ExtensionAPI, readonly sessionId: string = randomUUID()) {
|
|
53
|
+
this.snapshot.id = sessionId;
|
|
54
|
+
}
|
|
53
55
|
|
|
54
56
|
setContext(ctx: ExtensionContext) {
|
|
55
57
|
this.ctx = ctx;
|
|
@@ -59,10 +61,32 @@ export class CompanionBridge implements AskChannel {
|
|
|
59
61
|
|
|
60
62
|
setName(name?: string) {
|
|
61
63
|
this.snapshot.name = name ?? null;
|
|
62
|
-
this.snapshot.shortTitle = name
|
|
64
|
+
this.snapshot.shortTitle = this.contextTitle(name, this.snapshot.cwd);
|
|
63
65
|
this.send({ type: "session.update", session: { name: name ?? null, shortTitle: this.snapshot.shortTitle } });
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** Input arrives before Pi appends the user message to session history. */
|
|
69
|
+
setPrompt(text: string) {
|
|
70
|
+
if (text.trim()) this.pendingPrompt ??= text;
|
|
71
|
+
this.refreshMetadata();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private contextTitle(name: string | null | undefined, cwd: string) {
|
|
75
|
+
if (name?.trim()) return name.trim();
|
|
76
|
+
const entries = this.ctx?.sessionManager?.getBranch?.() ?? [];
|
|
77
|
+
let prompt = this.pendingPrompt;
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
80
|
+
const content = entry.message.content;
|
|
81
|
+
const text = typeof content === "string" ? content : content
|
|
82
|
+
.filter(part => part.type === "text").map(part => part.text).join(" ");
|
|
83
|
+
if (text.trim()) { prompt = text; break; }
|
|
84
|
+
}
|
|
85
|
+
const title = prompt?.replace(/\s+/g, " ").trim();
|
|
86
|
+
if (title) return title.length > 120 ? title.slice(0, 119) + "…" : title;
|
|
87
|
+
return cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
/** Context getters remain live while idle; send only changed metadata, including explicit clears. */
|
|
67
91
|
refreshMetadata() {
|
|
68
92
|
const ctx = this.ctx;
|
|
@@ -72,7 +96,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
72
96
|
if ((this.snapshot.mainModel ?? null) !== (ctx.model?.id ?? null)) this.telemetry.invalidateContext();
|
|
73
97
|
const next = {
|
|
74
98
|
name, cwd,
|
|
75
|
-
shortTitle: name
|
|
99
|
+
shortTitle: this.contextTitle(name, cwd),
|
|
76
100
|
mainModel: ctx.model?.id ?? null,
|
|
77
101
|
effort: ctx.thinkingLevel ?? this.pi.getThinkingLevel?.() ?? null,
|
|
78
102
|
telemetry: this.telemetry.snapshot(ctx),
|
|
@@ -91,6 +115,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
invalidateContext() {
|
|
118
|
+
this.pendingPrompt = undefined;
|
|
94
119
|
this.telemetry.invalidateContext();
|
|
95
120
|
}
|
|
96
121
|
|
package/src/index.ts
CHANGED
|
@@ -92,15 +92,23 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
pi.on("session_start", (_event, ctx) => {
|
|
95
|
-
//
|
|
95
|
+
// Restore only this session's explicit opt-in, never the previous session's.
|
|
96
96
|
sharingGeneration += 1;
|
|
97
97
|
quotas.stop();
|
|
98
98
|
quotas = new ProviderQuotaPoller(value => bridge.ingestTelemetry(value, 'subscription limits'));
|
|
99
99
|
bridge.close();
|
|
100
|
-
bridge = new CompanionBridge(pi);
|
|
100
|
+
bridge = new CompanionBridge(pi, ctx.sessionManager?.getSessionId?.());
|
|
101
101
|
bridge.setContext(ctx);
|
|
102
102
|
bridge.emit("session.start", { cwd: ctx.cwd });
|
|
103
|
-
|
|
103
|
+
const sharing = ctx.sessionManager?.getEntries?.().filter(entry =>
|
|
104
|
+
entry.type === "custom" && entry.customType === "companion:sharing"
|
|
105
|
+
).at(-1);
|
|
106
|
+
const saved = sharing?.type === "custom" ? sharing.data as { enabled?: boolean; sessionId?: string } | undefined : undefined;
|
|
107
|
+
if (saved?.enabled === true && saved.sessionId === bridge.sessionId) {
|
|
108
|
+
void setSharing(true, ctx, false);
|
|
109
|
+
} else {
|
|
110
|
+
void checkDaemon(ctx);
|
|
111
|
+
}
|
|
104
112
|
if (daemonMonitor) clearInterval(daemonMonitor);
|
|
105
113
|
daemonMonitor = setInterval(() => {
|
|
106
114
|
void checkDaemon(ctx);
|
|
@@ -108,6 +116,10 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
108
116
|
}, 30_000);
|
|
109
117
|
daemonMonitor.unref();
|
|
110
118
|
});
|
|
119
|
+
pi.on("input", (event, ctx) => {
|
|
120
|
+
bridge.setContext(ctx);
|
|
121
|
+
bridge.setPrompt(event.text);
|
|
122
|
+
});
|
|
111
123
|
pi.on("session_info_changed", (event, ctx) => {
|
|
112
124
|
bridge.setContext(ctx);
|
|
113
125
|
bridge.setName(event.name);
|
|
@@ -306,10 +318,11 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
306
318
|
}
|
|
307
319
|
});
|
|
308
320
|
|
|
309
|
-
async function setSharing(enabled: boolean, ctx: ExtensionContext) {
|
|
321
|
+
async function setSharing(enabled: boolean, ctx: ExtensionContext, persist = true) {
|
|
310
322
|
const generation = ++sharingGeneration;
|
|
311
323
|
bridge.setContext(ctx);
|
|
312
324
|
if (!enabled) {
|
|
325
|
+
if (persist) pi.appendEntry("companion:sharing", { enabled: false, sessionId: bridge.sessionId });
|
|
313
326
|
bridge.setRemoteEnabled(false);
|
|
314
327
|
ctx.ui.notify("Remote control disabled; Pi continues locally. The daemon remains available.", "info");
|
|
315
328
|
return;
|
|
@@ -321,6 +334,7 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
321
334
|
ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
|
|
322
335
|
return;
|
|
323
336
|
}
|
|
337
|
+
if (persist) pi.appendEntry("companion:sharing", { enabled: true, sessionId: bridge.sessionId });
|
|
324
338
|
bridge.activate();
|
|
325
339
|
bridge.setRemoteEnabled(true);
|
|
326
340
|
void quotas.refresh(ctx);
|