free-pi-cli 0.6.20 → 0.6.21

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
@@ -19,7 +19,7 @@ npm install -g free-pi-cli
19
19
  free-pi-cli
20
20
  ```
21
21
 
22
- Requires Node 22.19 or newer. The command is `free-pi-cli`, not `pi`.
22
+ Requires Node 22.19 or newer. The command is `free-pi-cli`, not `pi`. If the built-in agent suggests `pi --session ...` on exit, run `npx free-pi-cli@latest --session <id>` instead (or `free-pi-cli --session <id>` if you installed it globally).
23
23
 
24
24
  ## Usage
25
25
 
@@ -63,6 +63,31 @@ free-pi may use your sessions to improve and train models. See the privacy polic
63
63
 
64
64
  The agent can read files anywhere your user account can, but it only reads what your request needs. Project settings and extensions in a folder load only after you trust that folder.
65
65
 
66
+ ## Development
67
+
68
+ ```
69
+ bun install
70
+ bun test # runs against an in-process stub server; no real network or pi TUI
71
+ bun run typecheck
72
+ bun run build
73
+ ```
74
+
75
+ `FREEPI_BASE_URL` overrides the backend base URL (default `https://api.freepi.ai`), for pointing at a local dev server.
76
+
77
+ ## Repo map
78
+
79
+ | Path | Purpose |
80
+ |---|---|
81
+ | `src/index.ts`, `src/main.ts` | bin entry and top-level wiring |
82
+ | `src/run.ts` | orchestration: consent, login, token check, launch |
83
+ | `src/auth-flow.ts`, `src/credentials.ts` | sign-in clients; token storage (mode 0600) |
84
+ | `src/consent.ts` | consent screen text and parsing |
85
+ | `src/pi-launch.ts`, `src/provider.ts` | pi SDK integration: registers the free-pi provider and launches the TUI |
86
+ | `src/*-tool.ts`, `src/web-tools.ts`, `src/fusion.ts` | agent tools (images, docs, usage, todo, web) and Fusion model selection |
87
+ | `src/vendor/` | copies of the internal `shared` and `pi-ads` packages |
88
+ | `test/` | tests, one file per source module |
89
+ | `skills/` | bundled agent skills |
90
+
66
91
  ## Links
67
92
 
68
93
  - Website: https://freepi.ai
@@ -70,3 +95,4 @@ The agent can read files anywhere your user account can, but it only reads what
70
95
  - Terms: https://freepi.ai/terms
71
96
  - Privacy: https://freepi.ai/privacy
72
97
  - Changelog: [CHANGELOG.md](CHANGELOG.md)
98
+ - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). This repo is a one-way mirror of a private monorepo, and the file explains how pull requests flow back.
package/dist/index.js CHANGED
@@ -27,4 +27,4 @@ if (err) {
27
27
  console.error(err);
28
28
  process.exit(1);
29
29
  }
30
- await import("./main-qswbbh2q.js");
30
+ await import("./main-7sxf1v5b.js");
@@ -515,7 +515,7 @@ function folderMatches(cwd, sessionCwd) {
515
515
  // package.json
516
516
  var package_default = {
517
517
  name: "free-pi-cli",
518
- version: "0.6.20",
518
+ version: "0.6.21",
519
519
  description: "Free AI coding agent for your terminal. Ads and a daily free allowance pay for the models; sign in with GitHub and run npx free-pi-cli.",
520
520
  keywords: [
521
521
  "coding agent",
@@ -6021,6 +6021,8 @@ var AdNextResponseSchema = object({
6021
6021
  });
6022
6022
  var GRAVITY_CONTEXT_HEADER = "x-gravity-context";
6023
6023
  var GRAVITY_CONTEXT_MAX_BYTES = 8192;
6024
+ var AD_CONTEXT_HEADER = "x-ad-context";
6025
+ var AD_CONTEXT_MAX_CHARS = 1000;
6024
6026
  var AdImpressionRequestSchema = object({
6025
6027
  ad_id: string2(),
6026
6028
  click_token: string2(),
@@ -6767,6 +6769,52 @@ function gravityHeader(deps) {
6767
6769
  return {};
6768
6770
  }
6769
6771
  }
6772
+ function textParts(content) {
6773
+ if (typeof content === "string")
6774
+ return content;
6775
+ if (!Array.isArray(content))
6776
+ return "";
6777
+ return content.map((p) => p && p.type === "text" && typeof p.text === "string" ? p.text : "").filter(Boolean).join(`
6778
+ `);
6779
+ }
6780
+ function cut(redact, text) {
6781
+ const full = redact(text.slice(0, 20000));
6782
+ const s = full.slice(0, AD_CONTEXT_MAX_CHARS);
6783
+ return full.length > s.length && /\S/.test(full[s.length]) ? s.replace(/\S+$/, "").trimEnd() : s;
6784
+ }
6785
+ function adContextHeader(deps, ctx, prompt) {
6786
+ if (!deps.adContext || !ctx)
6787
+ return;
6788
+ try {
6789
+ const out = {};
6790
+ const entries = ctx.sessionManager.getBranch();
6791
+ if (prompt?.trim())
6792
+ out.user = cut(deps.adContext.redact, prompt.trim());
6793
+ for (let i = entries.length - 1;i >= 0 && !(out.user && out.assistant); i--) {
6794
+ const m = entries[i].type === "message" ? entries[i].message : undefined;
6795
+ if (m?.role !== "user" && m?.role !== "assistant" || out[m.role])
6796
+ continue;
6797
+ const text = textParts(m.content).trim();
6798
+ if (text)
6799
+ out[m.role] = cut(deps.adContext.redact, text);
6800
+ }
6801
+ if (!out.user && !out.assistant)
6802
+ return;
6803
+ const limit = Math.min(4096, 16 * 1024 - 2500 - (deps.getGravityContext?.()?.length ?? 0));
6804
+ if (limit < 600)
6805
+ return;
6806
+ let encoded = Buffer.from(JSON.stringify(out)).toString("base64url");
6807
+ while (encoded.length > limit && (out.user || out.assistant)) {
6808
+ for (const k of ["user", "assistant"])
6809
+ if (out[k])
6810
+ out[k] = out[k].slice(0, Math.floor(out[k].length / 2));
6811
+ encoded = Buffer.from(JSON.stringify(out)).toString("base64url");
6812
+ }
6813
+ return encoded;
6814
+ } catch {
6815
+ return;
6816
+ }
6817
+ }
6770
6818
  var IMP_TIMEOUT_MS = 3000;
6771
6819
  function isGravityImpUrl(raw) {
6772
6820
  if (!raw)
@@ -6786,9 +6834,11 @@ function fireGravityImpression(deps, ad) {
6786
6834
  } catch {}
6787
6835
  return true;
6788
6836
  }
6789
- async function fetchAdNext(deps, slot, trigger) {
6837
+ async function fetchAdNext(deps, slot, trigger, ctx, prompt) {
6790
6838
  try {
6791
- const res = await authedFetch(deps, `/ads/next?slot=${slot}`, trigger ? { headers: { "x-ad-trigger": trigger } } : undefined);
6839
+ const pair = adContextHeader(deps, ctx, prompt);
6840
+ const headers = { ...trigger ? { "x-ad-trigger": trigger } : {}, ...pair ? { [AD_CONTEXT_HEADER]: pair } : {} };
6841
+ const res = await authedFetch(deps, `/ads/next?slot=${slot}`, { headers });
6792
6842
  if (res.status === 204 || !res.ok)
6793
6843
  return;
6794
6844
  return await res.json();
@@ -6888,8 +6938,8 @@ function createBannerRenderer(opts = {}) {
6888
6938
  let lastPostedToken;
6889
6939
  const screen = opts.screen ?? {};
6890
6940
  return {
6891
- async render(deps, ctx, config, trigger) {
6892
- const ad = await fetchAdNext(deps, "banner", trigger);
6941
+ async render(deps, ctx, config, trigger, prompt) {
6942
+ const ad = await fetchAdNext(deps, "banner", trigger, ctx, prompt);
6893
6943
  if (opts.isActive && !opts.isActive())
6894
6944
  return;
6895
6945
  if (!ad) {
@@ -6947,7 +6997,7 @@ function createInlineRenderer(screen = {}) {
6947
6997
  return;
6948
6998
  if (screen.banner === "gravity")
6949
6999
  return;
6950
- const ad = await fetchAdNext(deps, "inline");
7000
+ const ad = await fetchAdNext(deps, "inline", undefined, ctx);
6951
7001
  if (!ad)
6952
7002
  return;
6953
7003
  const isGravity = ad.source === "gravity";
@@ -7165,6 +7215,7 @@ function createAdsExtension(opts) {
7165
7215
  clientVersion: opts.clientVersion,
7166
7216
  fetchImpl: opts.fetchImpl,
7167
7217
  getGravityContext: opts.getGravityContext,
7218
+ adContext: opts.adContext,
7168
7219
  onMe: opts.onMe
7169
7220
  };
7170
7221
  const config = loadConfig();
@@ -7180,14 +7231,14 @@ function createAdsExtension(opts) {
7180
7231
  let launchAt = 0;
7181
7232
  let lastBannerAt = 0;
7182
7233
  let bannerInFlight = false;
7183
- const renderBanner = async (ctx, trigger, force = false) => {
7234
+ const renderBanner = async (ctx, trigger, force = false, prompt) => {
7184
7235
  if (stopped)
7185
7236
  return;
7186
7237
  if (!force && (bannerInFlight || Date.now() - lastBannerAt < BANNER_MIN_REFRESH_MS))
7187
7238
  return;
7188
7239
  bannerInFlight = true;
7189
7240
  try {
7190
- await banner.render(deps, ctx, config, trigger);
7241
+ await banner.render(deps, ctx, config, trigger, prompt);
7191
7242
  } finally {
7192
7243
  bannerInFlight = false;
7193
7244
  lastBannerAt = Date.now();
@@ -7211,7 +7262,7 @@ function createAdsExtension(opts) {
7211
7262
  await Promise.all([renderBanner(ctx, "launch", true), renderMeter(deps, ctx)]);
7212
7263
  });
7213
7264
  const LAUNCH_AD_MAX_AGE_MS = 60 * 60000;
7214
- const newMessage = async (ctx) => {
7265
+ const newMessage = async (ctx, prompt) => {
7215
7266
  if (launchTurnUnused) {
7216
7267
  launchTurnUnused = false;
7217
7268
  const token = await Promise.resolve(deps.getToken()).catch(() => {
@@ -7221,17 +7272,17 @@ function createAdsExtension(opts) {
7221
7272
  return;
7222
7273
  }
7223
7274
  startTurn();
7224
- renderBanner(ctx, "user_message", true);
7275
+ renderBanner(ctx, "user_message", true, prompt);
7225
7276
  };
7226
7277
  pi.on("before_agent_start", async (event, ctx) => {
7227
7278
  if (event.messageType !== "user")
7228
7279
  return;
7229
- await newMessage(ctx);
7280
+ await newMessage(ctx, event.prompt);
7230
7281
  });
7231
7282
  pi.on("input", (event, ctx) => {
7232
7283
  const e = event;
7233
7284
  if (e.streamingBehavior && e.source !== "extension")
7234
- return newMessage(ctx).then(() => ({ action: "continue" }));
7285
+ return newMessage(ctx, e.text).then(() => ({ action: "continue" }));
7235
7286
  return { action: "continue" };
7236
7287
  });
7237
7288
  pi.on("turn_end", async (_event, ctx) => {
@@ -8572,7 +8623,6 @@ session.
8572
8623
 
8573
8624
  ## Commands
8574
8625
  - \`/usage\` — spend and remaining budget today
8575
- - \`/support\` — visit today's advertiser
8576
8626
  - \`/feedback <text>\` — send feedback to the free-pi team
8577
8627
  - \`/tos\` — open the Terms of Service
8578
8628
  - \`/privacy-policy\` — open the Privacy Policy
@@ -8608,9 +8658,6 @@ in to a server that uses OAuth, and \`free-pi-cli mcp logout <server>\`. Add
8608
8658
  Inside a session, \`/mcp\` shows each server and its tools, and lets you sign
8609
8659
  in, reconnect, or turn a server on or off.
8610
8660
 
8611
- ## Support
8612
- Today's sponsor keeps free-pi free. When the user asks how to help the
8613
- project, or seems to like free-pi, suggest \`/support\` to visit the sponsor.
8614
8661
  More at https://freepi.ai
8615
8662
  `;
8616
8663
 
@@ -9682,9 +9729,16 @@ function createUpgradeNoticeExtension(opts) {
9682
9729
 
9683
9730
  // src/tool-outcomes.ts
9684
9731
  var MAX_IDS = 50;
9732
+ var TOOL_META_HEADER = "x-freepi-tool-meta";
9733
+ var COMPACTED_HEADER = "x-freepi-compacted";
9734
+ var META_BUDGET = 1500;
9685
9735
  function createToolOutcomesExtension() {
9686
9736
  const failed = [];
9687
9737
  let evicted = false;
9738
+ const meta = [];
9739
+ let metaBytes = 3;
9740
+ let droppedMessages = 0;
9741
+ let pendingDropped = 0;
9688
9742
  return {
9689
9743
  name: "free-pi-tool-outcomes",
9690
9744
  factory(pi) {
@@ -9698,7 +9752,37 @@ function createToolOutcomesExtension() {
9698
9752
  evicted = true;
9699
9753
  }
9700
9754
  });
9755
+ pi.on("tool_execution_end", (event) => {
9756
+ const e = event;
9757
+ if (!e.toolCallId || !/^[\w.:-]{1,100}$/.test(e.toolCallId) || typeof e.durationMs !== "number")
9758
+ return;
9759
+ let exit = "-";
9760
+ if (e.toolName === "bash" || e.toolName === "powershell") {
9761
+ const m = e.isError ? /Command exited with code (\d{1,3})\s*$/.exec(e.result?.content?.map((b) => b.text ?? "").join("") ?? "") : null;
9762
+ exit = m ? m[1] : e.isError ? "-" : "0";
9763
+ }
9764
+ const entry = `${e.toolCallId}~${Math.max(0, Math.round(e.durationMs))}~${exit}~${e.result?.details?.truncation?.truncated ? 1 : 0}`;
9765
+ if (entry.length > 150)
9766
+ return;
9767
+ meta.push(entry);
9768
+ metaBytes += entry.length + 1;
9769
+ while (metaBytes > META_BUDGET && meta.length > 1)
9770
+ metaBytes -= meta.shift().length + 1;
9771
+ });
9772
+ pi.on("session_before_compact", (event) => {
9773
+ const p = event.preparation;
9774
+ pendingDropped = (p?.messagesToSummarize?.length ?? 0) + (p?.turnPrefixMessages?.length ?? 0);
9775
+ });
9776
+ pi.on("session_compact", () => {
9777
+ droppedMessages = pendingDropped;
9778
+ });
9701
9779
  pi.on("before_provider_headers", (event) => {
9780
+ if (meta.length)
9781
+ event.headers[TOOL_META_HEADER] = `v1:${meta.join(",")}`;
9782
+ if (droppedMessages) {
9783
+ event.headers[COMPACTED_HEADER] = `v1:${droppedMessages}`;
9784
+ droppedMessages = 0;
9785
+ }
9702
9786
  event.headers["x-freepi-tool-errors"] = `${evicted ? "v1x" : "v1"}:${failed.join(",")}`;
9703
9787
  });
9704
9788
  }
@@ -10141,6 +10225,10 @@ function mergeAttribution(a, b) {
10141
10225
 
10142
10226
  // src/changelog.ts
10143
10227
  var CHANGELOG_HIGHLIGHTS = [
10228
+ {
10229
+ version: "0.6.21",
10230
+ highlights: ["The /support command is removed."]
10231
+ },
10144
10232
  {
10145
10233
  version: "0.6.20",
10146
10234
  highlights: ["Error messages are now plain sentences instead of raw server text."]
@@ -11634,15 +11722,6 @@ async function buyCredits(opts, ctx) {
11634
11722
  (opts.openBrowserImpl ?? openBrowser)(page.url);
11635
11723
  ctx.ui.notify(buyPageText(page.url), "info");
11636
11724
  }
11637
- async function openAdvertiser(opts, ctx) {
11638
- const url = opts.getAdvertiserUrl?.();
11639
- if (!url) {
11640
- ctx.ui.notify("No advertiser loaded yet.", "info");
11641
- return;
11642
- }
11643
- (opts.openBrowserImpl ?? openBrowser)(url);
11644
- ctx.ui.notify("Opening today's advertiser in your browser. Thank you for supporting free-pi.", "info");
11645
- }
11646
11725
  var TERMS_URL = "https://freepi.ai/terms";
11647
11726
  var PRIVACY_URL = "https://freepi.ai/privacy";
11648
11727
  async function openTos(opts, ctx) {
@@ -11988,10 +12067,6 @@ function createFreePiCommandsExtension(opts) {
11988
12067
  description: FEEDBACK_DESCRIPTION,
11989
12068
  handler: (args, ctx) => sendFeedback(opts, args, ctx)
11990
12069
  });
11991
- pi.registerCommand("support", {
11992
- description: "Open today's advertiser in your browser to support free-pi.",
11993
- handler: (_args, ctx) => openAdvertiser(opts, ctx)
11994
- });
11995
12070
  pi.registerCommand("invite", {
11996
12071
  description: "Get your invite code and link: you and a friend both get +5k credits/day.",
11997
12072
  handler: (_args, ctx) => showInvite(opts, ctx)
@@ -12254,7 +12329,6 @@ var ALL_COMMANDS = [
12254
12329
  { name: "/usage", description: "credits left today and refill time" },
12255
12330
  { name: "/import", description: "continue a Claude Code or Codex session" },
12256
12331
  { name: "/hive", description: "share this session with friends" },
12257
- { name: "/support", description: "visit today's advertiser" },
12258
12332
  { name: "/tos", description: "terms of service" },
12259
12333
  { name: "/privacy-policy", description: "privacy policy" },
12260
12334
  { name: "/buy-credits", description: "get more usage" },
@@ -13477,6 +13551,10 @@ function createHiveExtension(opts) {
13477
13551
  roomWaiter = undefined;
13478
13552
  }
13479
13553
  async function start(ctx) {
13554
+ if (opts.isRemoteAttached?.()) {
13555
+ notify("Hive: turn the Telegram remote off first (/remote off).");
13556
+ return;
13557
+ }
13480
13558
  if (ws || starting) {
13481
13559
  notify(roomUrl ? `Hive room: ${roomUrl}` : "Hive: still connecting...");
13482
13560
  return;
@@ -13805,6 +13883,414 @@ ${decision.reason}`, ACTION_DIALOG_MS);
13805
13883
  };
13806
13884
  }
13807
13885
 
13886
+ // src/remote.ts
13887
+ import { randomUUID as randomUUID2 } from "node:crypto";
13888
+ import { basename as basename5 } from "node:path";
13889
+
13890
+ // ../shared/src/remote.ts
13891
+ var REMOTE_MAX_TITLE_CHARS = 80;
13892
+ var REMOTE_MAX_COMMAND_CHARS = 2000;
13893
+ var IdSchema = string2().min(1).max(64);
13894
+ var RemoteChoiceSchema = _enum(["once", "turn", "deny"]);
13895
+ var RemoteSessionFrameSchema = discriminatedUnion("type", [
13896
+ HiveAuthFrameSchema,
13897
+ object({ type: literal("hello"), session_id: IdSchema, title: string2().max(REMOTE_MAX_TITLE_CHARS) }),
13898
+ object({ type: literal("entry"), entry: HiveEntrySchema }),
13899
+ object({ type: literal("busy"), settled: boolean2() }),
13900
+ object({ type: literal("prompt_result"), prompt_id: IdSchema, accepted: boolean2(), reason: string2().max(100).optional() }),
13901
+ object({ type: literal("question"), id: IdSchema, text: string2().max(500), command: string2().max(REMOTE_MAX_COMMAND_CHARS) }),
13902
+ object({ type: literal("question_closed"), id: IdSchema })
13903
+ ]);
13904
+ var RemoteServerFrameSchema = discriminatedUnion("type", [
13905
+ object({ type: literal("ready"), linked: boolean2(), link_url: string2().optional() }),
13906
+ object({ type: literal("prompt"), prompt_id: IdSchema, text: string2().min(1).max(HIVE_MAX_PROMPT_CHARS) }),
13907
+ object({ type: literal("answer"), question_id: IdSchema, choice: RemoteChoiceSchema }),
13908
+ object({ type: literal("stop") }),
13909
+ object({ type: literal("error"), code: string2() }),
13910
+ object({ type: literal("closed"), reason: string2() })
13911
+ ]);
13912
+ var RemoteLinkCodeResponseSchema = object({ url: string2(), expires_at: string2() });
13913
+ var RemoteLinkStatusSchema = object({ linked: boolean2() });
13914
+
13915
+ // src/remote.ts
13916
+ var OPEN2 = 1;
13917
+ var MCP_RESOURCE_TOOLS3 = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
13918
+ var HARD_BLOCKED = new Set([SUBAGENT_TOOL, SEND_MESSAGE_TOOL, BUY_TOOL_NAME]);
13919
+ function wsUrl2(baseUrl) {
13920
+ const url = new URL("/remote/ws", baseUrl);
13921
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
13922
+ return url.toString();
13923
+ }
13924
+ function createRemoteExtension(opts) {
13925
+ return {
13926
+ name: "free-pi-remote",
13927
+ factory(pi) {
13928
+ let ws;
13929
+ let ctxRef;
13930
+ let attachedCwd = "";
13931
+ let title = "";
13932
+ let settled = true;
13933
+ let compacting = false;
13934
+ let allowTurn = false;
13935
+ let localDetach = false;
13936
+ let reconnectAttempt = 0;
13937
+ let reconnectTimer;
13938
+ let injectTimer;
13939
+ let questionChain = Promise.resolve();
13940
+ let openQuestion;
13941
+ const notify = (message, type = "info") => ctxRef?.ui.notify(message, type);
13942
+ const send = (frame) => {
13943
+ if (ws?.readyState === OPEN2)
13944
+ ws.send(JSON.stringify(frame));
13945
+ };
13946
+ const sendEntry = (entry) => send({ type: "entry", entry });
13947
+ function setSettled(next) {
13948
+ if (settled === next)
13949
+ return;
13950
+ settled = next;
13951
+ send({ type: "busy", settled });
13952
+ }
13953
+ function finishQuestion(choice, reason) {
13954
+ const question = openQuestion;
13955
+ if (!question)
13956
+ return;
13957
+ openQuestion = undefined;
13958
+ question.finish(choice, reason);
13959
+ if (choice === undefined)
13960
+ send({ type: "question_closed", id: question.id });
13961
+ }
13962
+ function cancelQuestion(reason) {
13963
+ finishQuestion(undefined, reason);
13964
+ }
13965
+ function clearRemoteState(reason = "turn ended") {
13966
+ opts.state.remoteTurn = false;
13967
+ allowTurn = false;
13968
+ cancelQuestion(reason);
13969
+ }
13970
+ function endApprovals(reason) {
13971
+ allowTurn = false;
13972
+ cancelQuestion(reason);
13973
+ }
13974
+ function recoverRejectedPrompt() {
13975
+ setSettled(true);
13976
+ clearRemoteState();
13977
+ }
13978
+ function clearSocket() {
13979
+ const old = ws;
13980
+ ws = undefined;
13981
+ clearTimeout(reconnectTimer);
13982
+ reconnectTimer = undefined;
13983
+ ctxRef?.ui.setStatus("remote", undefined);
13984
+ cancelQuestion("disconnected");
13985
+ try {
13986
+ old?.close();
13987
+ } catch {}
13988
+ }
13989
+ function detach(notice) {
13990
+ localDetach = true;
13991
+ opts.state.attached = false;
13992
+ endApprovals("detached");
13993
+ clearSocket();
13994
+ attachedCwd = "";
13995
+ reconnectAttempt = 0;
13996
+ if (notice)
13997
+ notify(notice);
13998
+ }
13999
+ function questionFor(event, ctx) {
14000
+ const toolName = String(event.toolName);
14001
+ const input = event.input ?? {};
14002
+ const summary = toolName === "bash" ? String(input.command ?? "") : toolName + " " + JSON.stringify(input);
14003
+ const command = summary.slice(0, 2000);
14004
+ const run = async () => {
14005
+ if (!opts.state.remoteTurn)
14006
+ return { block: true, reason: "Remote: turn ended before approval" };
14007
+ if (allowTurn)
14008
+ return;
14009
+ if (ws?.readyState !== OPEN2)
14010
+ return { block: true, reason: "Remote: disconnected before approval" };
14011
+ const id = randomUUID2();
14012
+ let timer;
14013
+ let abortListener;
14014
+ const answer = await new Promise((resolve) => {
14015
+ openQuestion = {
14016
+ id,
14017
+ finish: (choice, reason) => {
14018
+ clearTimeout(timer);
14019
+ if (abortListener && ctx.signal)
14020
+ ctx.signal.removeEventListener("abort", abortListener);
14021
+ resolve({ choice, reason });
14022
+ }
14023
+ };
14024
+ const timeoutMs = opts.questionTimeoutMs ?? 5 * 60000;
14025
+ timer = setTimeout(() => finishQuestion(undefined, "timed out"), timeoutMs);
14026
+ timer.unref?.();
14027
+ abortListener = () => finishQuestion(undefined, "turn aborted");
14028
+ if (ctx.signal?.aborted)
14029
+ abortListener();
14030
+ else
14031
+ ctx.signal?.addEventListener("abort", abortListener, { once: true });
14032
+ send({ type: "question", id, text: "The agent wants to run this:", command });
14033
+ });
14034
+ if (answer.choice === "deny")
14035
+ return { block: true, reason: "Remote: denied from Telegram" };
14036
+ if (answer.choice === "turn")
14037
+ allowTurn = true;
14038
+ if (answer.choice === "once" || answer.choice === "turn")
14039
+ return;
14040
+ const why = answer.reason ?? "approval ended";
14041
+ return { block: true, reason: "Remote: " + why };
14042
+ };
14043
+ const current = questionChain.then(run, run);
14044
+ questionChain = current.catch(() => {
14045
+ return;
14046
+ });
14047
+ return current;
14048
+ }
14049
+ function handle(frame) {
14050
+ switch (frame.type) {
14051
+ case "ready":
14052
+ if (!frame.linked && frame.link_url) {
14053
+ notify("Remote: open this link in Telegram to connect your account: " + frame.link_url);
14054
+ } else if (frame.linked) {
14055
+ notify("Remote is on. Messages you send to the free-pi bot in Telegram go to this session.");
14056
+ }
14057
+ ctxRef?.ui.setStatus("remote", "remote: on");
14058
+ reconnectAttempt = 0;
14059
+ if (!settled)
14060
+ send({ type: "busy", settled: false });
14061
+ break;
14062
+ case "prompt": {
14063
+ if (!settled || compacting) {
14064
+ send({ type: "prompt_result", prompt_id: frame.prompt_id, accepted: false, reason: "busy" });
14065
+ break;
14066
+ }
14067
+ opts.state.remoteTurn = true;
14068
+ allowTurn = false;
14069
+ setSettled(false);
14070
+ clearTimeout(injectTimer);
14071
+ injectTimer = setTimeout(() => {
14072
+ injectTimer = undefined;
14073
+ if (ctxRef?.isIdle() !== false)
14074
+ recoverRejectedPrompt();
14075
+ }, opts.injectGraceMs ?? 2000);
14076
+ injectTimer.unref?.();
14077
+ try {
14078
+ Promise.resolve(pi.sendUserMessage(frame.text, { expandPromptTemplates: false })).catch(recoverRejectedPrompt);
14079
+ send({ type: "prompt_result", prompt_id: frame.prompt_id, accepted: true });
14080
+ } catch {
14081
+ recoverRejectedPrompt();
14082
+ send({ type: "prompt_result", prompt_id: frame.prompt_id, accepted: false, reason: "error" });
14083
+ }
14084
+ break;
14085
+ }
14086
+ case "answer":
14087
+ if (openQuestion?.id === frame.question_id)
14088
+ finishQuestion(frame.choice);
14089
+ break;
14090
+ case "stop":
14091
+ cancelQuestion("stopped");
14092
+ if (!settled)
14093
+ ctxRef?.abort();
14094
+ break;
14095
+ case "closed":
14096
+ if (frame.reason === "shutdown")
14097
+ break;
14098
+ detach(frame.reason === "replaced" ? "Remote moved to another session." : "Remote closed.");
14099
+ break;
14100
+ case "error":
14101
+ notify("Remote: the server could not process the connection.", "warning");
14102
+ break;
14103
+ }
14104
+ }
14105
+ function connect() {
14106
+ let socket;
14107
+ try {
14108
+ const Impl = opts.WebSocketImpl ?? WebSocket;
14109
+ socket = new Impl(wsUrl2(opts.baseUrl));
14110
+ } catch {
14111
+ retryConnect();
14112
+ return;
14113
+ }
14114
+ ws = socket;
14115
+ localDetach = false;
14116
+ socket.onopen = async () => {
14117
+ if (ws !== socket)
14118
+ return;
14119
+ try {
14120
+ const token = await opts.getToken();
14121
+ if (ws !== socket)
14122
+ return;
14123
+ socket.send(JSON.stringify({ type: "auth", token }));
14124
+ socket.send(JSON.stringify({ type: "hello", session_id: opts.sessionId, title }));
14125
+ } catch {
14126
+ socket.close();
14127
+ }
14128
+ };
14129
+ socket.onerror = () => {
14130
+ if (ws === socket)
14131
+ socket.close();
14132
+ };
14133
+ socket.onclose = () => {
14134
+ if (ws !== socket)
14135
+ return;
14136
+ ws = undefined;
14137
+ ctxRef?.ui.setStatus("remote", undefined);
14138
+ cancelQuestion("disconnected");
14139
+ if (!localDetach)
14140
+ retryConnect();
14141
+ };
14142
+ socket.onmessage = (event) => {
14143
+ if (ws !== socket || typeof event.data !== "string")
14144
+ return;
14145
+ let json;
14146
+ try {
14147
+ json = JSON.parse(event.data);
14148
+ } catch {
14149
+ return;
14150
+ }
14151
+ const parsed = RemoteServerFrameSchema.safeParse(json);
14152
+ if (parsed.success)
14153
+ handle(parsed.data);
14154
+ };
14155
+ }
14156
+ function retryConnect() {
14157
+ if (localDetach)
14158
+ return;
14159
+ const delays = opts.reconnectDelaysMs ?? [1000, 2000, 5000, 15000, 30000];
14160
+ if (reconnectAttempt >= delays.length) {
14161
+ localDetach = true;
14162
+ opts.state.attached = false;
14163
+ endApprovals("disconnected");
14164
+ notify("Remote: lost the connection. Run /remote to attach again.", "warning");
14165
+ return;
14166
+ }
14167
+ const delay = delays[reconnectAttempt++];
14168
+ reconnectTimer = setTimeout(() => {
14169
+ reconnectTimer = undefined;
14170
+ connect();
14171
+ }, delay);
14172
+ reconnectTimer.unref?.();
14173
+ }
14174
+ function attach(ctx) {
14175
+ if (ws || reconnectTimer) {
14176
+ notify("Remote is already attached.");
14177
+ return;
14178
+ }
14179
+ ctxRef = ctx;
14180
+ if (opts.isHiveOpen?.()) {
14181
+ notify("Remote: end the Hive room first (/hive end).");
14182
+ return;
14183
+ }
14184
+ opts.state.attached = true;
14185
+ attachedCwd = ctx.cwd;
14186
+ title = basename5(ctx.cwd).slice(0, 80);
14187
+ settled = ctx.isIdle();
14188
+ compacting = false;
14189
+ localDetach = false;
14190
+ reconnectAttempt = 0;
14191
+ connect();
14192
+ }
14193
+ pi.on("tool_call", (event, ctx) => {
14194
+ if (!opts.state.remoteTurn)
14195
+ return;
14196
+ ctxRef = ctx;
14197
+ if (HARD_BLOCKED.has(event.toolName)) {
14198
+ return { block: true, reason: "Remote: this tool is not run for a prompt sent from Telegram" };
14199
+ }
14200
+ const needsApproval = event.toolName === "bash" || event.toolName.startsWith("mcp__") || MCP_RESOURCE_TOOLS3.includes(event.toolName) || Boolean(event.parentToolCallId);
14201
+ return needsApproval ? questionFor(event, ctx) : undefined;
14202
+ });
14203
+ pi.on("agent_start", () => {
14204
+ clearTimeout(injectTimer);
14205
+ injectTimer = undefined;
14206
+ setSettled(false);
14207
+ });
14208
+ pi.on("agent_settled", () => {
14209
+ setSettled(true);
14210
+ clearRemoteState();
14211
+ sendEntry({ kind: "settled" });
14212
+ });
14213
+ pi.on("session_before_compact", (_event, ctx) => {
14214
+ ctxRef = ctx;
14215
+ compacting = true;
14216
+ setSettled(false);
14217
+ });
14218
+ const compactionEnded = (_event, ctx) => {
14219
+ ctxRef = ctx;
14220
+ compacting = false;
14221
+ if (ctx.isIdle())
14222
+ setSettled(true);
14223
+ };
14224
+ pi.on("session_compact", compactionEnded);
14225
+ pi.on("session_compact_failed", compactionEnded);
14226
+ pi.on("session_start", (_event, ctx) => {
14227
+ ctxRef = ctx;
14228
+ if (attachedCwd && ctx.cwd !== attachedCwd)
14229
+ detach();
14230
+ });
14231
+ pi.on("session_before_switch", () => detach());
14232
+ pi.on("session_shutdown", () => detach());
14233
+ pi.on("input", (event) => {
14234
+ if (event.source !== "extension")
14235
+ sendEntry({ kind: "prompt", text: event.text.slice(0, HIVE_MAX_PROMPT_CHARS) });
14236
+ return { action: "continue" };
14237
+ });
14238
+ pi.on("message_end", (event) => {
14239
+ const message = event.message;
14240
+ if (message.role !== "assistant" || !Array.isArray(message.content))
14241
+ return;
14242
+ const text = message.content.map((part) => part.type === "text" && typeof part.text === "string" ? part.text : "").join("");
14243
+ for (let i = 0;i < text.length; i += HIVE_MAX_ASSISTANT_CHARS) {
14244
+ sendEntry({ kind: "assistant", text: text.slice(i, i + HIVE_MAX_ASSISTANT_CHARS) });
14245
+ }
14246
+ });
14247
+ pi.on("tool_execution_end", (event) => {
14248
+ sendEntry({ kind: "tool", name: event.toolName, is_error: event.isError });
14249
+ });
14250
+ if (process.env.FREEPI_REMOTE === "1") {
14251
+ pi.registerCommand("remote", {
14252
+ description: "Attach this session to Telegram: /remote, /remote off, /remote unlink",
14253
+ handler: async (args, ctx) => {
14254
+ ctxRef = ctx;
14255
+ const sub = args.trim().toLowerCase();
14256
+ if (sub === "")
14257
+ return attach(ctx);
14258
+ if (sub === "off") {
14259
+ detach();
14260
+ notify("Remote is off.");
14261
+ return;
14262
+ }
14263
+ if (sub === "unlink") {
14264
+ allowTurn = false;
14265
+ cancelQuestion("unlinked");
14266
+ const token = await opts.getToken().catch(() => {
14267
+ return;
14268
+ });
14269
+ if (!token) {
14270
+ notify("Remote: couldn't read your free-pi login.", "error");
14271
+ return;
14272
+ }
14273
+ try {
14274
+ const response = await (opts.fetchImpl ?? fetch)(opts.baseUrl + "/remote/link", {
14275
+ method: "DELETE",
14276
+ headers: { authorization: "Bearer " + token }
14277
+ });
14278
+ if (response.ok)
14279
+ detach();
14280
+ notify(response.ok ? "Remote account unlinked." : "Remote: couldn't unlink the account.", response.ok ? "info" : "warning");
14281
+ } catch {
14282
+ notify("Remote: couldn't unlink the account.", "warning");
14283
+ }
14284
+ return;
14285
+ }
14286
+ notify("Usage: /remote, /remote off, or /remote unlink.");
14287
+ }
14288
+ });
14289
+ }
14290
+ }
14291
+ };
14292
+ }
14293
+
13808
14294
  // src/shell-sandbox.ts
13809
14295
  import { spawnSync } from "node:child_process";
13810
14296
  import {
@@ -14765,7 +15251,6 @@ function buildRuntimeOptions(opts, sessionId) {
14765
15251
  name: "free-pi-provider",
14766
15252
  factory: (pi) => registerProviders(pi, sessionId)
14767
15253
  };
14768
- let advertiserUrl;
14769
15254
  const adsExtension = createAdsExtension({
14770
15255
  registerMeterRefresh: (refresh) => auth.setOnSignedIn(() => void refresh().catch(() => {})),
14771
15256
  baseUrl: opts.baseUrl,
@@ -14773,9 +15258,7 @@ function buildRuntimeOptions(opts, sessionId) {
14773
15258
  sessionId,
14774
15259
  clientVersion: CLI_VERSION,
14775
15260
  getGravityContext: opts.getGravityContext,
14776
- onBannerAd: (u) => {
14777
- advertiserUrl = u;
14778
- },
15261
+ adContext: opts.adContext,
14779
15262
  onMe: (me) => {
14780
15263
  updateAllowanceCache(me);
14781
15264
  }
@@ -14829,7 +15312,6 @@ function buildRuntimeOptions(opts, sessionId) {
14829
15312
  const commandsExtension = createFreePiCommandsExtension({
14830
15313
  baseUrl: opts.baseUrl,
14831
15314
  getToken: () => auth.getJwt(),
14832
- getAdvertiserUrl: () => advertiserUrl,
14833
15315
  credentialsPath: opts.credentialsPath,
14834
15316
  agentDir: opts.agentDir,
14835
15317
  isGuest: () => auth.isGuest(),
@@ -14860,12 +15342,21 @@ function buildRuntimeOptions(opts, sessionId) {
14860
15342
  const codemodeExtension = { name: "pi-codemode", factory: createCodemodeExtension({ models: false }) };
14861
15343
  const toolSearchExtension = { name: "pi-tool-search", factory: createToolSearchExtension() };
14862
15344
  const hiveRoom = { open: false };
15345
+ const remoteState = { remoteTurn: false, attached: false };
14863
15346
  const hiveExtension = createHiveExtension({
14864
15347
  baseUrl: opts.baseUrl,
14865
15348
  getToken: () => auth.getJwt(),
14866
15349
  knownTools: allowedTools,
14867
15350
  sandbox: shellSandbox,
14868
- roomState: hiveRoom
15351
+ roomState: hiveRoom,
15352
+ isRemoteAttached: () => remoteState.attached
15353
+ });
15354
+ const remoteExtension = createRemoteExtension({
15355
+ baseUrl: opts.baseUrl,
15356
+ getToken: async () => auth.getJwt(),
15357
+ sessionId,
15358
+ state: remoteState,
15359
+ isHiveOpen: () => hiveRoom.open
14869
15360
  });
14870
15361
  const choiceExtension = choiceOn ? createChoiceToolExtension({
14871
15362
  baseUrl: opts.baseUrl,
@@ -14873,7 +15364,7 @@ function buildRuntimeOptions(opts, sessionId) {
14873
15364
  sessionId,
14874
15365
  arm: () => opts.choiceArm ?? null,
14875
15366
  headless: !(tty.stdin && tty.stdout),
14876
- isRoomOpen: () => hiveRoom.open
15367
+ isRoomOpen: () => hiveRoom.open || remoteState.remoteTurn
14877
15368
  }) : undefined;
14878
15369
  const subagentsExtension = createSubagentsExtension({
14879
15370
  sessionId,
@@ -14918,6 +15409,7 @@ function buildRuntimeOptions(opts, sessionId) {
14918
15409
  toolSearchExtension,
14919
15410
  subagentsExtension,
14920
15411
  hiveExtension,
15412
+ remoteExtension,
14921
15413
  ...guestLoginExtension ? [guestLoginExtension] : [],
14922
15414
  ...opts.guest ? [createGuestNotesExtension({ isGuest: () => auth.isGuest() })] : []
14923
15415
  ];
@@ -15743,6 +16235,7 @@ Couldn't finish GitHub sign-in. Continuing as a guest.
15743
16235
  getGravityContext = deps.startGravityPixel?.(jwt);
15744
16236
  } catch {}
15745
16237
  }
16238
+ const adContext = consentCurrent && process.env.FREEPI_NO_GRAVITY_PIXEL !== "1" ? { redact: redactSecrets } : undefined;
15746
16239
  if (me)
15747
16240
  updateAllowanceCache(me);
15748
16241
  guestHandoffVariant = me?.experiments?.guest_handoff ?? guestHandoffVariant;
@@ -15868,6 +16361,7 @@ ${r.text}
15868
16361
  costArm: versionCheck.costArm,
15869
16362
  updateLatest,
15870
16363
  getGravityContext,
16364
+ adContext,
15871
16365
  guest: Boolean(me?.guest),
15872
16366
  refillCard: refillSession,
15873
16367
  allowanceLine: allowanceLine(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-pi-cli",
3
- "version": "0.6.20",
3
+ "version": "0.6.21",
4
4
  "description": "Free AI coding agent for your terminal. Ads and a daily free allowance pay for the models; sign in with GitHub and run npx free-pi-cli.",
5
5
  "keywords": [
6
6
  "coding agent",