@alphacoder-v0/pi-loops 0.24.0 → 0.24.1

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/src/pi-loops.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import * as path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
19
- import { ProjectTrustStore, SessionManager, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
19
+ import { ProjectTrustStore, SessionManager, VERSION as PI_VERSION, copyToClipboard, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
20
20
  import { ARCHIVE_EXT, type ActivateMode, applyImport, automationOf, defaultExportPath, exportSession, importSession, offerToEnable, parseActivateMode } from "./archive.ts";
21
21
  import { Box, Text } from "@earendil-works/pi-tui";
22
22
  import { Type } from "typebox";
@@ -38,7 +38,8 @@ import { cardLines, cardOrigin, claimPrompt, dismissOutcome, dismissTail, inboxC
38
38
  import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
39
39
  import { FRESH_RUNS_READ, burstLine, freshRuns, panelRuns, placeRunCards, runCardDelivery, runCardLines, runCardsHere, runLine } from "./run-card.ts";
40
40
  import { type ShareMessage, renderShare, shareSummary, shareableMessages } from "./share.ts";
41
- import { installLauncherWithConfirm, installedByPi, launcherSource, launcherState, launcherTarget, refreshLauncher, stopAndWait, stopService } from "./cli.ts";
41
+ import { UsageError, addLauncher, installLauncherWithConfirm, installedByPi, launcherAction, launcherSource, launcherState, launcherTarget, openPage, pageLaunchHost, refreshLauncher, slashDir, stopAndWait, stopService, uninstallLauncher, webPageRequest } from "./cli.ts";
42
+ import { copyText, isRemoteTty } from "./browser-open.ts";
42
43
  import { execFile } from "node:child_process";
43
44
  import { createHash } from "node:crypto";
44
45
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
@@ -2464,7 +2465,7 @@ export default function piLoops(pi: ExtensionAPI) {
2464
2465
  const ARCHIVE_WARNING = `warning: ${ARCHIVE_EXT} archives include transcript and tool history. They do not include separate auth stores, provider credentials, OAuth tokens, MCP config, or the inbox.`;
2465
2466
 
2466
2467
  pi.registerCommand("pi-loops", {
2467
- description: "About this extension; start | stop | restart | status the part that runs loops while no pi is open; install-launcher puts the `pi-loops` command on your PATH",
2468
+ description: "About this extension; start | stop | restart | status the part that runs loops while no pi is open; web opens this conversation in the browser page; install-launcher puts the `pi-loops` command on your PATH, uninstall-launcher removes it",
2468
2469
  handler: async (args, ctx) => {
2469
2470
  lastCtx = ctx;
2470
2471
  const sub = args.trim().split(/\s+/)[0] ?? "";
@@ -2491,6 +2492,37 @@ export default function piLoops(pi: ExtensionAPI) {
2491
2492
  await drainRequests({ tick: true });
2492
2493
  return;
2493
2494
  }
2495
+ if (sub === "web") {
2496
+ // The page on this conversation with no `pi-loops` on PATH: the same page-open and opener
2497
+ // the command line uses. Shown as a notice, not written into the conversation: the link
2498
+ // carries a one-use key.
2499
+ const lines: string[] = [];
2500
+ const host = { ...pageLaunchHost(dir), packageDir: PACKAGE_DIR, piPackage: piPackageDir(), startedBy: `the pi window pid ${process.pid}`, myPiVersion: PI_VERSION };
2501
+ const req = webPageRequest({ cwd: session.cwd, sessionId: binding.here().sessionId ?? session.sessionId, sessionFile: ctx.sessionManager.getSessionFile() });
2502
+ // The link is shown and copied before the browser is tried: the wait for a browser to spend
2503
+ // the key can take seconds, and the person may paste the link sooner.
2504
+ const onLink = async (link: string) => {
2505
+ // Said only when a clipboard command took the link. pi's copy can still get there through
2506
+ // OSC 52, which no terminal confirms, so that try says nothing either way.
2507
+ if (await copyText(link)) lines.push(" copied to the clipboard");
2508
+ else await copyToClipboard(link).catch((err: any) => log.info(`/pi-loops web: copy: ${err?.message ?? err}`));
2509
+ ctx.ui.notify(lines.join("\n"), "info");
2510
+ };
2511
+ const opened = await openPage(dir, req, { key: true, browser: isRemoteTty() ? "remote" : "yes", host }, (l) => lines.push(l), undefined, onLink);
2512
+ // pi shows one notice at a time, so the last one repeats the link with the outcome.
2513
+ ctx.ui.notify(lines.join("\n"), opened.code === 0 ? "info" : "warning");
2514
+ refreshBadge();
2515
+ return;
2516
+ }
2517
+ if (sub === "install-launcher" || sub === "uninstall-launcher") {
2518
+ try {
2519
+ slashDir(tokenize(splitCommand(args).rest).map((t) => t.value));
2520
+ } catch (err: any) {
2521
+ if (!(err instanceof UsageError)) throw err;
2522
+ ctx.ui.notify(`${err.message}. usage: /pi-loops ${sub} [--dir <dir>]`, "warning");
2523
+ return;
2524
+ }
2525
+ }
2494
2526
  if (sub === "install-launcher") {
2495
2527
  // The command line's own `install-launcher` cannot be reached until it has run once —
2496
2528
  // `pi install` does not put the package on your PATH. You are already inside pi with
@@ -2505,12 +2537,20 @@ export default function piLoops(pi: ExtensionAPI) {
2505
2537
  show(ctx, code === 0 ? "installed the pi-loops launcher" : "could not install the launcher", lines.map((l) => ` ${l.trim()}`));
2506
2538
  return;
2507
2539
  }
2540
+ if (sub === "uninstall-launcher") {
2541
+ const lines: string[] = [];
2542
+ const code = uninstallLauncher(slashDir(tokenize(splitCommand(args).rest).map((t) => t.value)), (l) => lines.push(l));
2543
+ show(ctx, code === 0 ? "the pi-loops launcher is gone" : "left the launcher alone", lines.map((l) => ` ${l.trim()}`));
2544
+ return;
2545
+ }
2508
2546
  show(ctx, `pi-loops ${PI_LOOPS_VERSION}`, [
2509
2547
  ` loops directory: ${homeRel(dir)}`,
2510
2548
  ` package: ${homeRel(PACKAGE_DIR)}`,
2511
2549
  "",
2512
2550
  " /pi-loops start | stop | restart | status",
2551
+ " /pi-loops web open this conversation in the browser page",
2513
2552
  " /pi-loops install-launcher [--dir <dir>] put the `pi-loops` command on your PATH",
2553
+ " /pi-loops uninstall-launcher [--dir <dir>] remove it, so no session writes it again",
2514
2554
  " /cron · /triggers · /inbox · /goal",
2515
2555
  ]);
2516
2556
  },
@@ -3160,7 +3200,7 @@ export default function piLoops(pi: ExtensionAPI) {
3160
3200
  /* -------------------------------------------------------- lifecycle */
3161
3201
 
3162
3202
  /**
3163
- * The `pi-loops` command, kept on the PATH of whoever asked for it there.
3203
+ * The `pi-loops` command, put on the PATH and kept working there.
3164
3204
  *
3165
3205
  * `pi install` puts this package under pi's directory and nothing on the PATH, so until 0.22.1
3166
3206
  * the first thing a new user had to do was type a bare `node …/cli-entry.mjs install-launcher`
@@ -3169,40 +3209,47 @@ export default function piLoops(pi: ExtensionAPI) {
3169
3209
  * it runs and where a launcher would go, so the step belongs here.
3170
3210
  *
3171
3211
  * This is the extension's only write outside the loops directory (AGENTS.md). Its limit is one
3172
- * file, `pi-loops` in a directory already on the PATH, and only where a launcher of ours already
3173
- * is or after a yes. A file with no marker of ours is somebody's own wrapper and is left alone;
3174
- * one naming another copy of this package that is still on disk is that copy's to keep current,
3175
- * so an install and a checkout both being opened never trade the file back and forth; a no is
3176
- * remembered in `ui.json`, so it is asked once rather than in every window; and a checkout is
3177
- * never asked for a launcher that is not there at all — nobody ran `pi install`, so nothing is
3178
- * missing from their PATH — though one written from a checkout is still kept working.
3212
+ * file, `pi-loops` in a directory already on the PATH. With nothing there, the first interactive
3213
+ * session of a copy `pi install` put in place writes it with no question and says so in one line,
3214
+ * as pi's own installer does for `pi` (#180); after that only a launcher of ours is rewritten. A
3215
+ * file that does not open with our header is somebody's own wrapper and is left alone; one naming another
3216
+ * copy of this package that is still on disk is that copy's to keep current, so an install and a
3217
+ * checkout both being opened never trade the file back and forth; `uninstall-launcher` is
3218
+ * remembered in `ui.json`, so no later session writes it again; a write that failed is
3219
+ * remembered with its directory, so it is not tried in every window; and a checkout never writes
3220
+ * a launcher that is not there at all — nobody ran `pi install`, so nothing is missing from their
3221
+ * PATH — though one written from a checkout is still kept working. The decision is
3222
+ * `launcherAction` in src/cli.ts.
3179
3223
  */
3180
3224
  async function keepLauncher(ctx: ExtensionContext): Promise<void> {
3181
3225
  const { entryDir, node, agentDir } = launcherSource();
3182
3226
  const target = launcherTarget(undefined);
3183
- // Nothing on the PATH to write into. `/pi-loops install-launcher`, run by hand, is where that
3184
- // is explained; a session start saying it unprompted would be a warning about nothing.
3185
- if (!target) return;
3186
- const { state } = launcherState(entryDir, node, agentDir, target);
3187
- if (state === "stale") {
3227
+ const prefs = readUiPrefs(dir);
3228
+ const state = target ? launcherState(entryDir, node, agentDir, target).state : undefined;
3229
+ const action = launcherAction({ target, state, hasUI: ctx.hasUI, declined: prefs.launcher === "declined", failedIn: prefs.launcher === "failed" ? prefs.launcherFailedIn : undefined, installedByPi: state === "absent" && installedByPi() });
3230
+ if (!target || action === "none") return;
3231
+ if (action === "refresh") {
3188
3232
  const done = refreshLauncher(entryDir, node, agentDir, target);
3189
3233
  if (!done) return;
3190
3234
  log.info(`launcher: ${done.message}`);
3191
- // No question: they asked for a launcher once, and keeping that one working is what they
3192
- // asked for. The line is so that a file changing under them is never a silent change.
3235
+ // They have a launcher of ours, and keeping that one working is what it is for. The line is
3236
+ // so that a file changing under them is never a silent change.
3193
3237
  if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${done.message}`, done.ok ? "info" : "warning");
3194
3238
  return;
3195
3239
  }
3196
- if (state !== "absent") return; // ours and current, another copy's, or somebody else's entirely
3197
- if (!ctx.hasUI || readUiPrefs(dir).launcher === "declined" || !installedByPi()) return;
3198
- const lines: string[] = [];
3199
- const code = await installLauncherWithConfirm([], (title, body) => ctx.ui.confirm(title, body), (l) => lines.push(l));
3200
- if (code === undefined) {
3201
- writeUiPref(dir, "launcher", "declined");
3202
- ctx.ui.notify("[pi-loops] not installed — `/pi-loops install-launcher` writes one whenever you want it", "info");
3240
+ const added = addLauncher(entryDir, node, agentDir, target);
3241
+ if (added.kind === "none") return;
3242
+ if (added.kind === "added") {
3243
+ // A failed write into another directory is not news any more.
3244
+ clearUiPref(dir, "launcher");
3245
+ clearUiPref(dir, "launcherFailedIn");
3246
+ log.info(`launcher: wrote ${added.file}`);
3247
+ ctx.ui.notify(`[pi-loops] added pi-loops to ${homeRel(target)}; /pi-loops uninstall-launcher removes it`, "info");
3203
3248
  return;
3204
3249
  }
3205
- ctx.ui.notify(`[pi-loops] ${lines.map((l) => l.trim()).join(" · ")}`, code === 0 ? "info" : "warning");
3250
+ writeUiPref(dir, "launcher", "failed");
3251
+ writeUiPref(dir, "launcherFailedIn", target);
3252
+ ctx.ui.notify(`[pi-loops] ${added.message}; /pi-loops install-launcher --dir <dir> writes it elsewhere`, "warning");
3206
3253
  }
3207
3254
 
3208
3255
  /**
@@ -3277,8 +3324,7 @@ export default function piLoops(pi: ExtensionAPI) {
3277
3324
  loadMcpConfig(windowTrusted(getAgentDir(), ctx.cwd, ctx.isProjectTrusted(), APPROVED));
3278
3325
  for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
3279
3326
  await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
3280
- // Not awaited: the question below is a dialog, and the clock must not wait behind someone who
3281
- // has walked away from it.
3327
+ // A launcher that cannot be written or kept is logged, and never stops the session starting.
3282
3328
  void keepLauncher(ctx).catch((err: any) => log.info(`launcher: ${err?.message ?? err}`));
3283
3329
  // tui and rpc processes stay alive and run the clock; `PI_LOOPS_HOST=1` starts it in a `pi -p`
3284
3330
  // run too, which queues its conversation's due prompts but hands none to pi (the window
package/src/ui-prefs.ts CHANGED
@@ -25,10 +25,13 @@ export interface UiPrefs {
25
25
  model?: string;
26
26
  thinking?: string;
27
27
  /**
28
- * `"declined"` once someone has said no to the launcher a session start offers, so the offer is
29
- * made once and not in every window. `/pi-loops install-launcher` clears it when it writes one.
28
+ * `"declined"` once someone has removed the launcher with `uninstall-launcher`, so no session
29
+ * start writes it again; `"failed"` once a session start could not write it. `/pi-loops
30
+ * install-launcher` clears it when it writes one.
30
31
  */
31
32
  launcher?: string;
33
+ /** The directory a session start could not write the launcher into: tried again only elsewhere. */
34
+ launcherFailedIn?: string;
32
35
  /** `"stopped"` once a person stopped pi-loops by hand: no window starts it again until `pi-loops start`. */
33
36
  host?: string;
34
37
  /** `"shown"` once a session start has said that closed conversations' plain jobs now run in the host. */
@@ -65,6 +68,7 @@ export function readUiPrefs(dir: string): UiPrefs {
65
68
  if (typeof doc.model === "string") prefs.model = doc.model;
66
69
  if (typeof doc.thinking === "string") prefs.thinking = doc.thinking;
67
70
  if (typeof doc.launcher === "string") prefs.launcher = doc.launcher;
71
+ if (typeof doc.launcherFailedIn === "string") prefs.launcherFailedIn = doc.launcherFailedIn;
68
72
  if (typeof doc.host === "string") prefs.host = doc.host;
69
73
  if (typeof doc.heldJobsNotice === "string") prefs.heldJobsNotice = doc.heldJobsNotice;
70
74
  if (typeof doc.toolsCapNotice === "string") prefs.toolsCapNotice = doc.toolsCapNotice;
package/src/web.mjs CHANGED
@@ -11,7 +11,8 @@ export const DOOR = `<!doctype html><meta charset="utf-8"><title>pi-loops</title
11
11
  <meta name="color-scheme" content="light dark">
12
12
  <body style="font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;background:Canvas;color:CanvasText">
13
13
  <h2 style="font-weight:600">This browser has not been here before.</h2>
14
- <p>Run <code>pi-loops</code> in a terminal on this machine, or type the code from <b>add device</b> on a browser that is signed in:</p>
14
+ <p>Run <code>pi-loops open</code> in a terminal on this machine for a link that signs this browser in.</p>
15
+ <p>Pairing another device? <code>pi-loops pair</code> shows a code.</p>
15
16
  <form method="get" style="display:flex;gap:.5rem">
16
17
  <input name="pair" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6"
17
18
  placeholder="000000" style="font:inherit;letter-spacing:.3em;padding:.55rem .7rem;flex:1;min-width:0;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit">