paseo-smart-session 1.2.4 → 1.2.6

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
@@ -37,13 +37,13 @@ That history remains useful after an individual session ends or a provider cache
37
37
  Install from npm on Paseo 0.9.0 or newer:
38
38
 
39
39
  ```sh
40
- paseo plugin install npm:paseo-smart-session
40
+ paseo plugin install npm:paseo-smart-session@1.2.6
41
41
  ```
42
42
 
43
43
  Paseo 0.8 can install the same plugin from Git:
44
44
 
45
45
  ```sh
46
- paseo plugin add tomgrin10/paseo-smart-session
46
+ paseo plugin add tomgrin10/paseo-smart-session --ref v1.2.6
47
47
  ```
48
48
 
49
49
  ```sh
package/client/pill.tsx CHANGED
@@ -197,9 +197,27 @@ function AutoCompactIcon({
197
197
  return <Icon name="FoldVertical" size={size} color={color} />;
198
198
  }
199
199
 
200
- /** The lean shape this needs from the agent list, which carries far more. */
200
+ /** The lean shapes this needs from the agent directory, which carries far more. */
201
+ interface ListedAgent {
202
+ readonly id?: string;
203
+ readonly workspaceId?: string | null;
204
+ }
205
+
206
+ interface AgentUpdate {
207
+ readonly kind: "remove" | "upsert";
208
+ readonly agentId?: string;
209
+ readonly agent?: ListedAgent;
210
+ }
211
+
201
212
  interface AgentListing {
202
- readonly entries?: readonly { readonly agent?: { id?: string; workspaceId?: string | null } }[];
213
+ readonly entries?: readonly { readonly agent?: ListedAgent }[];
214
+ readonly subscription?: {
215
+ subscribe(observer: {
216
+ snapshot(listing: AgentListing): void;
217
+ update(message: { readonly type?: string; readonly payload?: AgentUpdate }): void;
218
+ }): () => void;
219
+ release(): Promise<void>;
220
+ };
203
221
  }
204
222
 
205
223
  /**
@@ -227,6 +245,10 @@ function updatePill(handle: PillHandle, title: string): void {
227
245
  export function contributeClient(client: PluginClientContext) {
228
246
  /** Every agent this app knows of, and its pill registration while one is up. */
229
247
  const tracked = new Map<string, { workspaceId: string; handle: PillHandle | null }>();
248
+ const lifetime = new AbortController();
249
+ let stopped = false;
250
+ let unsubscribeOwnedObservation: (() => void) | null = null;
251
+ let releaseAgentObservation: (() => Promise<void>) | null = null;
230
252
 
231
253
  function register(agentId: string, entry: { workspaceId: string; handle: PillHandle | null }): void {
232
254
  const toggle = async () => {
@@ -299,42 +321,114 @@ export function contributeClient(client: PluginClientContext) {
299
321
  tracked.delete(agentId);
300
322
  }
301
323
 
324
+ function applyAgentUpdate(update: AgentUpdate): void {
325
+ if (update.kind === "remove") {
326
+ if (typeof update.agentId === "string") forget(update.agentId);
327
+ return;
328
+ }
329
+ const agent = update.agent;
330
+ if (
331
+ typeof agent?.id !== "string" ||
332
+ typeof agent.workspaceId !== "string" ||
333
+ agent.workspaceId === ""
334
+ ) return;
335
+ track(agent.id, agent.workspaceId);
336
+ }
337
+
338
+ function trackAgents(entries: AgentListing["entries"]): Set<string> {
339
+ const present = new Set<string>();
340
+ for (const entry of entries ?? []) {
341
+ const agent = entry.agent;
342
+ if (
343
+ typeof agent?.id !== "string" ||
344
+ typeof agent.workspaceId !== "string" ||
345
+ agent.workspaceId === ""
346
+ ) continue;
347
+ present.add(agent.id);
348
+ track(agent.id, agent.workspaceId);
349
+ }
350
+ return present;
351
+ }
352
+
353
+ /** A restored observation replaces its directory snapshot, including removals. */
354
+ function replaceAgents(entries: AgentListing["entries"]): void {
355
+ const present = trackAgents(entries);
356
+ for (const agentId of [...tracked.keys()]) {
357
+ if (!present.has(agentId)) forget(agentId);
358
+ }
359
+ }
360
+
302
361
  const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
303
362
 
304
363
  const unsubscribeStore = subscribe(sync);
305
364
 
306
- const unsubscribeAgents = client.paseo.agents.subscribe((update) => {
307
- if (update.kind === "remove") {
308
- forget(update.agentId);
309
- return;
310
- }
311
- const { id, workspaceId } = update.agent;
312
- if (typeof workspaceId !== "string" || workspaceId === "") return;
313
- track(id, workspaceId);
365
+ // v0.8 returns only the listing and emits its subscribed updates through this
366
+ // local listener. Keep it attached until a newer runtime gives us the owned
367
+ // observation whose snapshots can reconcile the whole directory on reconnect.
368
+ let unsubscribeLegacyAgents: (() => void) | null = client.paseo.agents.subscribe((update) => {
369
+ applyAgentUpdate(update as AgentUpdate);
314
370
  });
315
371
 
316
372
  // The subscription only carries agents that report in from now on, and an idle
317
373
  // one may not do that for hours.
318
374
  void (async () => {
319
375
  try {
320
- const listing = (await client.paseo.agents.list()) as unknown as AgentListing;
321
- for (const entry of listing.entries ?? []) {
322
- const agent = entry.agent;
323
- if (typeof agent?.id !== "string" || typeof agent.workspaceId !== "string") continue;
324
- track(agent.id, agent.workspaceId);
376
+ // `agents.subscribe()` listens only to observations already owned by
377
+ // this API instance. A plain list stopped receiving newly created agents
378
+ // in Paseo 0.9, which made both plugin pills disappear on new sessions.
379
+ const listAgents = client.paseo.agents.list as unknown as (options: {
380
+ subscribe: Record<string, never>;
381
+ signal: AbortSignal;
382
+ }) => Promise<AgentListing>;
383
+ const listing = await listAgents({ subscribe: {}, signal: lifetime.signal });
384
+ const release = listing.subscription
385
+ ? () => listing.subscription!.release()
386
+ : null;
387
+ if (stopped) {
388
+ await release?.();
389
+ return;
390
+ }
391
+ releaseAgentObservation = release;
392
+ // The legacy listener was already live while this snapshot was in flight,
393
+ // so bootstrap must be additive or it can erase a just-created agent.
394
+ trackAgents(listing.entries);
395
+ if (listing.subscription) {
396
+ unsubscribeOwnedObservation = listing.subscription.subscribe({
397
+ snapshot(snapshot) {
398
+ if (!stopped) replaceAgents(snapshot.entries);
399
+ },
400
+ update(message) {
401
+ if (!stopped && message.type === "agent_update" && message.payload) {
402
+ applyAgentUpdate(message.payload);
403
+ }
404
+ },
405
+ });
406
+ unsubscribeLegacyAgents?.();
407
+ unsubscribeLegacyAgents = null;
325
408
  }
326
409
  } catch {
327
- // Not fatal: the subscription still picks up every agent that speaks next.
410
+ const release = releaseAgentObservation;
411
+ releaseAgentObservation = null;
412
+ await release?.().catch(() => undefined);
413
+ // Not fatal to the rest of the client contribution; a reload can retry.
328
414
  }
329
415
  })();
330
416
 
331
417
  void refresh().catch(() => undefined);
332
418
  const timer = setInterval(() => void refresh().catch(() => undefined), REFRESH_MS);
333
419
 
334
- return () => {
420
+ return async () => {
421
+ stopped = true;
422
+ lifetime.abort();
335
423
  clearInterval(timer);
336
- unsubscribeAgents();
424
+ unsubscribeLegacyAgents?.();
425
+ unsubscribeLegacyAgents = null;
426
+ unsubscribeOwnedObservation?.();
427
+ unsubscribeOwnedObservation = null;
337
428
  unsubscribeStore();
338
429
  for (const agentId of [...tracked.keys()]) forget(agentId);
430
+ const release = releaseAgentObservation;
431
+ releaseAgentObservation = null;
432
+ await release?.().catch(() => undefined);
339
433
  };
340
434
  }
package/mcp.mjs CHANGED
@@ -71,6 +71,12 @@ const log = (...args) => console.error("[smart-session mcp]", ...args);
71
71
  * installed. On a managed Git install the fallback instead finds the `paseo` CLI
72
72
  * on `PATH` and resolves the client from its own installed dependencies — the
73
73
  * same package the running daemon was built with.
74
+ *
75
+ * The desktop app is the exception. Its client lives inside `app.asar`, which
76
+ * only Electron can read, and its `paseo` is a shell wrapper with no
77
+ * `node_modules` beside it. `server/install.ts` therefore registers this server
78
+ * to run on the app's own Electron runtime in node mode, and from there the
79
+ * client resolves through `process.resourcesPath`.
74
80
  */
75
81
  let daemonClientModule = null;
76
82
 
@@ -78,22 +84,38 @@ const CLIENT_SPECIFIER = ["@getpaseo", "client", "internal", "daemon-client"].jo
78
84
 
79
85
  async function loadDaemonClient() {
80
86
  if (daemonClientModule !== null) return daemonClientModule;
81
- try {
82
- daemonClientModule = await import(CLIENT_SPECIFIER);
83
- return daemonClientModule;
84
- } catch (directError) {
85
- const cliPath = findPaseoCli();
86
- if (cliPath === null) throw directError;
87
+ const failures = [];
88
+ for (const { label, resolve } of clientCandidates()) {
87
89
  try {
88
- const resolved = createRequire(cliPath).resolve(CLIENT_SPECIFIER);
89
- daemonClientModule = await import(pathToFileURL(resolved).href);
90
+ daemonClientModule = await import(resolve());
90
91
  return daemonClientModule;
91
- } catch (viaCliError) {
92
- throw new Error(
93
- `Cannot resolve ${CLIENT_SPECIFIER} directly (${directError.message}) or via the paseo CLI at ${cliPath} (${viaCliError.message})`,
94
- );
92
+ } catch (error) {
93
+ failures.push(`${label} (${error.message})`);
95
94
  }
96
95
  }
96
+ throw new Error(`Cannot resolve ${CLIENT_SPECIFIER} ${failures.join(" or ")}${desktopHint()}`);
97
+ }
98
+
99
+ /** Every place the client can come from, cheapest first. */
100
+ function clientCandidates() {
101
+ const fromEntry = (entry) => () => pathToFileURL(createRequire(entry).resolve(CLIENT_SPECIFIER)).href;
102
+ const candidates = [{ label: "directly", resolve: () => CLIENT_SPECIFIER }];
103
+ // Set only under Electron, where the bundled CLI inside app.asar is readable.
104
+ if (typeof process.resourcesPath === "string") {
105
+ const entry = join(process.resourcesPath, "app.asar", "node_modules", "@getpaseo", "cli", "dist", "index.js");
106
+ candidates.push({ label: `from the Paseo app bundle at ${process.resourcesPath}`, resolve: fromEntry(entry) });
107
+ }
108
+ const cliPath = findPaseoCli();
109
+ if (cliPath !== null) candidates.push({ label: `via the paseo CLI at ${cliPath}`, resolve: fromEntry(cliPath) });
110
+ return candidates;
111
+ }
112
+
113
+ /** Explains the one failure a person can fix without reading the code. */
114
+ function desktopHint() {
115
+ if (process.versions.electron !== undefined) return "";
116
+ const cliPath = findPaseoCli();
117
+ if (cliPath === null || !existsSync(join(dirname(cliPath), "..", "app.asar"))) return "";
118
+ return ". The paseo CLI is the desktop app's, whose client only its own runtime can load: reload the smart-session plugin so it re-registers this MCP server on that runtime, then restart the Claude Code session";
97
119
  }
98
120
 
99
121
  /** Finds `paseo` on PATH and resolves symlinks, the way a shell's `which` would. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "1.2.4",
4
+ "version": "1.2.6",
5
5
  "license": "MIT",
6
6
  "description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
7
7
  "repository": {
package/server/install.ts CHANGED
@@ -198,6 +198,45 @@ async function writeAtomic(path: string, text: string): Promise<void> {
198
198
  await rename(temp, path);
199
199
  }
200
200
 
201
+ export interface McpDefinition {
202
+ type: "stdio";
203
+ command: string;
204
+ args: string[];
205
+ env?: Record<string, string>;
206
+ }
207
+
208
+ export interface McpRuntime {
209
+ electron: string | undefined;
210
+ execPath: string;
211
+ appImage: string | undefined;
212
+ }
213
+
214
+ const currentRuntime = (): McpRuntime => ({
215
+ electron: process.versions.electron,
216
+ execPath: process.execPath,
217
+ appImage: process.env.APPIMAGE,
218
+ });
219
+
220
+ /**
221
+ * How Claude Code should launch `mcp.mjs`.
222
+ *
223
+ * `node` from `PATH`, except under the desktop app. There the daemon client lives
224
+ * inside `app.asar`, which plain Node cannot read, so the server runs on the
225
+ * runtime this plugin is itself running on — Electron in node mode, the same way
226
+ * the app launches its bundled `paseo` CLI. An AppImage mounts at a fresh path on
227
+ * every launch, so its stable `$APPIMAGE` stands in for the mounted executable.
228
+ */
229
+ export function mcpDefinition(pluginDir: string, runtime: McpRuntime = currentRuntime()): McpDefinition {
230
+ const args = [join(pluginDir, "mcp.mjs")];
231
+ if (runtime.electron === undefined) return { type: "stdio", command: "node", args };
232
+ return {
233
+ type: "stdio",
234
+ command: runtime.appImage ?? runtime.execPath,
235
+ args,
236
+ env: { ELECTRON_RUN_AS_NODE: "1" },
237
+ };
238
+ }
239
+
201
240
  /**
202
241
  * Registers the agent-facing MCP server, through Claude Code's own CLI.
203
242
  *
@@ -208,34 +247,31 @@ async function writeAtomic(path: string, text: string): Promise<void> {
208
247
  * install into a new directory, so an entry can exist while its script no longer
209
248
  * does.
210
249
  */
211
- export function mcpPointsAtPlugin(output: string, pluginDir: string): boolean {
250
+ export function mcpPointsAtPlugin(output: string, definition: McpDefinition): boolean {
212
251
  const lines = output.split(/\r?\n/).map((line) => line.trim());
213
252
  return lines.includes("Scope: User config (available in all your projects)")
214
- && lines.includes("Command: node")
215
- && lines.includes(`Args: ${join(pluginDir, "mcp.mjs")}`);
253
+ && lines.includes(`Command: ${definition.command}`)
254
+ && lines.includes(`Args: ${definition.args.join(" ")}`)
255
+ && Object.entries(definition.env ?? {}).every(([key, value]) => lines.includes(`${key}=${value}`));
216
256
  }
217
257
 
218
258
  async function ensureMcp(pluginDir: string): Promise<InstallReport["mcp"]> {
219
259
  const run = (args: string[]): Promise<{ stdout: string }> =>
220
260
  execFileAsync("claude", args, { timeout: 20_000, maxBuffer: 1024 * 1024 });
261
+ const definition = mcpDefinition(pluginDir);
221
262
 
222
263
  let existing = false;
223
264
  try {
224
265
  const { stdout } = await run(["mcp", "get", "smart-session"]);
225
- if (mcpPointsAtPlugin(stdout, pluginDir)) return "present";
266
+ if (mcpPointsAtPlugin(stdout, definition)) return "present";
226
267
  existing = true;
227
268
  } catch {
228
269
  // Absent, or no `claude` on PATH. The add below tells the two apart.
229
270
  }
230
271
 
231
- const definition = JSON.stringify({
232
- type: "stdio",
233
- command: "node",
234
- args: [join(pluginDir, "mcp.mjs")],
235
- });
236
272
  try {
237
273
  if (existing) await run(["mcp", "remove", "smart-session", "--scope", "user"]);
238
- await run(["mcp", "add-json", "--scope", "user", "smart-session", definition]);
274
+ await run(["mcp", "add-json", "--scope", "user", "smart-session", JSON.stringify(definition)]);
239
275
  return existing ? "updated" : "added";
240
276
  } catch {
241
277
  // No `claude` binary on the daemon's PATH is the usual reason, and it is not