@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lightweight, local-first remote control for Pi sessions.
4
4
 
5
- **New in 0.3.6:** The extension automatically ensures the background daemon is available, while sessions stay private until `/remote-control`. Per-user services restart the daemon; concurrent startup is locked and upgrades verify the replacement first. Subscription limits are tracked separately for supported OAuth providers, and provider failures appear as clear messages in the activity feed.
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 the Pi session and running `/companion` can register it again.
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.6",
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
- readonly sessionId = randomUUID();
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: this.sessionId,
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?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
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?.trim() || cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
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
- // Switching or starting a Pi session must not inherit the previous opt-in.
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
- void checkDaemon(ctx);
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);