@freibergergarcia/phone-a-friend 2.0.1 → 2.1.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "phone-a-friend",
3
3
  "description": "CLI relay that lets AI coding agents collaborate by sending prompts and repository context to backend agents.",
4
- "version": "2.0.1",
4
+ "version": "2.1.0",
5
5
  "author": {
6
6
  "name": "Bruno Freiberger"
7
7
  }
@@ -86,8 +86,9 @@ I'm working on this task and got the above response. Please review it and return
86
86
  **Binary mode** (`RELAY_MODE = binary`):
87
87
  ```bash
88
88
  phone-a-friend --to codex --repo "$PWD" --prompt "<relay-prompt>" --context-text "<context-payload>" [--fast] [--session <id>]
89
- # For gemini, always include --model (see "Gemini Model Priority" below):
90
- phone-a-friend --to gemini --repo "$PWD" --prompt "<relay-prompt>" --context-text "<context-payload>" --model <model> [--fast] [--session <id>]
89
+ # For gemini, always include --model (see "Gemini Model Priority" below).
90
+ # Do NOT pass --session to gemini it will error (see "Session continuity" below):
91
+ phone-a-friend --to gemini --repo "$PWD" --prompt "<relay-prompt>" --context-text "<context-payload>" --model <model> [--fast]
91
92
  ```
92
93
 
93
94
  See "Speed optimization" and "Session continuity" below for when to
@@ -144,7 +145,7 @@ wants the same backend to apply fixes or dig deeper), reuse the session:
144
145
  3. On **subsequent** relays to the **same backend** in the same
145
146
  conversation, reuse the same session ID. The backend remembers previous
146
147
  turns.
147
- 4. If switching backends (e.g., first call to codex, second to gemini),
148
+ 4. If switching backends (e.g., first call to codex, second to ollama),
148
149
  generate a new session ID for the new backend. Sessions are
149
150
  backend-specific.
150
151
 
@@ -152,13 +153,20 @@ Benefits: the backend keeps full conversation history, so follow-up prompts
152
153
  can be shorter (no need to re-send context from previous turns).
153
154
 
154
155
  **Backend-specific behavior:**
155
- - **Claude, Codex**: native session resume. Follow-up prompts can send
156
- deltas only.
157
- - **Ollama**: replays full history each call. Sessions work but prompt size
158
- grows with each turn. Keep follow-ups concise.
159
- - **Gemini**: session resume is best-effort (may start fresh). Always
160
- include enough context for Gemini to answer independently, even in
161
- follow-up calls.
156
+ - **Codex, Claude, OpenCode**: native session resume. Follow-up prompts
157
+ can send deltas only.
158
+ - **Ollama**: replays full history each call. Sessions work but prompt
159
+ size grows with each turn. Keep follow-ups concise.
160
+ - **Gemini**: `--session` is **not supported**. PaF rejects it with a
161
+ RelayError (`--session is not supported by the gemini backend ...`).
162
+ Each Gemini relay call must be self-contained. Do not pass `--session`
163
+ with `--to gemini`.
164
+
165
+ On the FIRST relay under a new session label, PaF prints an informational
166
+ stderr line: `[phone-a-friend] Session label "..." not found in store.
167
+ Starting a fresh session under this label.` This is expected. The hint
168
+ about `--backend-session` in that line is for advanced use (see below)
169
+ and not relevant to the typical `/phone-a-friend` flow.
162
170
 
163
171
  **Omit `--session`** for one-off relays where no follow-up is expected.
164
172
  This is the common case. Only add `--session` when the user explicitly
@@ -166,6 +174,25 @@ asks for a follow-up or continuation of a previous relay.
166
174
 
167
175
  Session continuity is only available in binary mode (`RELAY_MODE = binary`).
168
176
 
177
+ ### Advanced: `--backend-session` (raw thread ID adoption)
178
+
179
+ If the user explicitly provides a Codex/Claude/OpenCode backend thread ID
180
+ that PaF did not create (e.g., from another tool or a previous CLI run),
181
+ attach to it with `--backend-session <id>` instead of `--session <id>`.
182
+ Combine with `--session <label>` to also start tracking under a label.
183
+
184
+ ```bash
185
+ # Resume a raw backend thread once (no PaF persistence):
186
+ phone-a-friend --to codex --repo "$PWD" --backend-session <thread-id> --prompt "<...>"
187
+
188
+ # Adopt: resume AND start tracking under a PaF label going forward:
189
+ phone-a-friend --to codex --repo "$PWD" --session <label> --backend-session <thread-id> --prompt "<...>"
190
+ ```
191
+
192
+ This is rarely the right move from inside a Claude Code conversation — the
193
+ common case is `--session <label>` with a fresh label. Only use
194
+ `--backend-session` when the user supplied a specific backend thread ID.
195
+
169
196
  ## Gemini Model Priority
170
197
 
171
198
  When using `--to gemini`, **always** pass `--model` using the first model from
@@ -233,8 +233,13 @@ command:
233
233
  - `SESSION_IDS` = map of backend name to session ID (binary mode only).
234
234
  Generated as `paf-team-<backend>-<task-slug>-<4-char-random>` (e.g.,
235
235
  `paf-team-codex-review-auth-b7e1`). The random suffix prevents
236
- collisions across runs and repos. One session ID per backend. Used in
237
- all relay calls so the backend remembers previous rounds.
236
+ collisions across runs and repos. One session ID per session-capable
237
+ backend. Used in relay calls so the backend remembers previous rounds.
238
+
239
+ **Generate session IDs only for `codex` and `ollama`.** Do NOT generate
240
+ one for `gemini` — the Gemini backend declares `resumeStrategy:
241
+ unsupported`, and PaF will reject `--session` for Gemini with a
242
+ RelayError. For `--backend both`, generate a session ID for codex only.
238
243
 
239
244
  ### Algorithm
240
245
 
@@ -274,7 +279,16 @@ command:
274
279
 
275
280
  phone-a-friend --to <backend> --repo "$PWD" --prompt "<prompt>" \
276
281
  [--context-text "<context>"] [--include-diff] [--sandbox <mode>] \
277
- [--model <model>] --fast --session <SESSION_ID>
282
+ [--model <model>] --fast [--session <SESSION_ID>]
283
+
284
+ Include `--session <SESSION_ID>` ONLY when <backend> is `codex` or
285
+ `ollama`. For `gemini`, omit `--session` entirely (PaF rejects it for
286
+ Gemini with a RelayError).
287
+
288
+ On the FIRST relay under a new session label, PaF prints an
289
+ informational stderr line: `[phone-a-friend] Session label "..." not
290
+ found in store. Starting a fresh session under this label.` This is
291
+ expected and not a failure.
278
292
 
279
293
  After the relay completes, send the FULL unedited output to the team
280
294
  lead via SendMessage. Include:
@@ -389,13 +403,19 @@ Delegate the task to the backend via the relay. The lead's job is to
389
403
 
390
404
  **Binary mode** (`RELAY_MODE = binary`):
391
405
  ```bash
392
- phone-a-friend --to <backend> --repo "$PWD" --prompt "<prompt>" [--context-text "<context>"] [--include-diff] [--sandbox <mode>] [--model <model>] --fast --session <SESSION_ID>
406
+ phone-a-friend --to <backend> --repo "$PWD" --prompt "<prompt>" [--context-text "<context>"] [--include-diff] [--sandbox <mode>] [--model <model>] --fast [--session <SESSION_ID>]
393
407
  ```
394
408
 
395
- Always include `--fast` (relay prompts are self-contained) and
396
- `--session` using the backend-specific ID from `SESSION_IDS`. The session
397
- lets the backend remember previous rounds, so follow-up prompts can focus
398
- on feedback deltas rather than re-sending full context.
409
+ Always include `--fast` (relay prompts are self-contained). Include
410
+ `--session` ONLY when `<backend>` is `codex` or `ollama` pass the
411
+ backend-specific ID from `SESSION_IDS`. For `gemini`, omit `--session`
412
+ entirely; PaF rejects `--session` against Gemini (resume strategy
413
+ declared `unsupported`).
414
+
415
+ When `--session` is used, the session lets the backend remember
416
+ previous rounds, so follow-up prompts can focus on feedback deltas
417
+ rather than re-sending full context. For Gemini (no session), each
418
+ round is stateless — see "Per-round relay rules" below.
399
419
 
400
420
  **Direct mode** (`RELAY_MODE = direct`):
401
421
  ```bash
@@ -511,18 +531,21 @@ codebase. Read at most 2-3 files for preflight context. The backend has
511
531
  not to become an expert on the codebase before delegating.
512
532
 
513
533
  **Per-round relay rules (binary mode with `--session`)**:
514
- When `--session` is active, session-capable backends (Claude, Codex)
515
- remember previous rounds natively. Each relay call only needs to send:
534
+ When `--session` is active, session-capable backends (Codex) remember
535
+ previous rounds natively. Each relay call only needs to send:
516
536
  - The specific feedback or revision request for this round
517
537
  - Any new context (e.g., updated diff after changes)
518
538
  Do NOT re-send the full task description, prior outputs, or summaries.
519
539
  The backend already has them in its session history.
520
540
 
521
- **Exception: Gemini and Ollama with `--session`**: Gemini may not resume
522
- natively (best-effort). Ollama replays full history each call (prompt size
523
- grows per turn). For these backends, always include enough context for the
524
- backend to answer independently, even in follow-up rounds. Include a brief
525
- task recap + the latest output alongside the feedback.
541
+ **Exception: Ollama with `--session`**: Ollama replays full history each
542
+ call (prompt size grows per turn). Sessions work but follow-up prompts
543
+ must stay concise to avoid hitting size limits.
544
+
545
+ **Exception: Gemini (no `--session`)**: Gemini runs stateless every round.
546
+ Each Gemini relay call must include enough context for the backend to
547
+ answer independently — include a brief task recap and the latest output
548
+ alongside the feedback in every round.
526
549
 
527
550
  **Per-round relay rules (direct mode, no session)**:
528
551
  - Each relay call sends ONLY:
package/dist/index.js CHANGED
@@ -5720,7 +5720,21 @@ var init_jobs = __esm({
5720
5720
  });
5721
5721
 
5722
5722
  // src/sessions.ts
5723
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
5723
+ var sessions_exports = {};
5724
+ __export(sessions_exports, {
5725
+ SessionStore: () => SessionStore
5726
+ });
5727
+ import {
5728
+ readFileSync as readFileSync4,
5729
+ writeFileSync as writeFileSync4,
5730
+ existsSync as existsSync4,
5731
+ mkdirSync as mkdirSync3,
5732
+ openSync,
5733
+ closeSync,
5734
+ fsyncSync,
5735
+ renameSync,
5736
+ unlinkSync
5737
+ } from "fs";
5724
5738
  import { dirname as dirname3, join as join4 } from "path";
5725
5739
  import { homedir as homedir3 } from "os";
5726
5740
  var MAX_SESSIONS, SessionStore;
@@ -5739,15 +5753,65 @@ var init_sessions = __esm({
5739
5753
  }
5740
5754
  load() {
5741
5755
  if (!existsSync4(this.filePath)) return [];
5756
+ let raw;
5742
5757
  try {
5743
- return JSON.parse(readFileSync4(this.filePath, "utf-8"));
5744
- } catch {
5758
+ raw = readFileSync4(this.filePath, "utf-8");
5759
+ } catch (err) {
5760
+ console.error(`[phone-a-friend] Failed to read session store ${this.filePath}: ${err.message}`);
5761
+ return [];
5762
+ }
5763
+ try {
5764
+ const parsed = JSON.parse(raw);
5765
+ if (!Array.isArray(parsed)) {
5766
+ throw new Error("session store is not a JSON array");
5767
+ }
5768
+ return parsed;
5769
+ } catch (err) {
5770
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5771
+ const rotated = `${this.filePath}.corrupt-${ts}`;
5772
+ try {
5773
+ renameSync(this.filePath, rotated);
5774
+ console.error(
5775
+ `[phone-a-friend] Session store at ${this.filePath} could not be parsed (${err.message}). Rotated to ${rotated}. Starting with an empty store.`
5776
+ );
5777
+ } catch (rotateErr) {
5778
+ console.error(
5779
+ `[phone-a-friend] Session store at ${this.filePath} could not be parsed (${err.message}) and could not be rotated (${rotateErr.message}). Starting with an empty store; the file will be overwritten on next write.`
5780
+ );
5781
+ }
5745
5782
  return [];
5746
5783
  }
5747
5784
  }
5748
5785
  save(sessions) {
5749
- mkdirSync3(dirname3(this.filePath), { recursive: true });
5750
- writeFileSync4(this.filePath, JSON.stringify(sessions, null, 2), "utf-8");
5786
+ const dir = dirname3(this.filePath);
5787
+ mkdirSync3(dir, { recursive: true });
5788
+ const tmpPath = `${this.filePath}.tmp.${process.pid}.${Date.now()}`;
5789
+ const payload = JSON.stringify(sessions, null, 2);
5790
+ const tmpFd = openSync(tmpPath, "w");
5791
+ try {
5792
+ try {
5793
+ writeFileSync4(tmpFd, payload, "utf-8");
5794
+ fsyncSync(tmpFd);
5795
+ } finally {
5796
+ closeSync(tmpFd);
5797
+ }
5798
+ renameSync(tmpPath, this.filePath);
5799
+ } catch (err) {
5800
+ try {
5801
+ unlinkSync(tmpPath);
5802
+ } catch {
5803
+ }
5804
+ throw err;
5805
+ }
5806
+ try {
5807
+ const dirFd = openSync(dir, "r");
5808
+ try {
5809
+ fsyncSync(dirFd);
5810
+ } finally {
5811
+ closeSync(dirFd);
5812
+ }
5813
+ } catch {
5814
+ }
5751
5815
  }
5752
5816
  get(id) {
5753
5817
  return this.load().find((session) => session.id === id) ?? null;
@@ -5755,7 +5819,35 @@ var init_sessions = __esm({
5755
5819
  list() {
5756
5820
  return this.load();
5757
5821
  }
5822
+ /** Remove a single session by label. Returns true if a row was removed. */
5823
+ delete(id) {
5824
+ const sessions = this.load();
5825
+ const filtered = sessions.filter((session) => session.id !== id);
5826
+ if (filtered.length === sessions.length) return false;
5827
+ this.save(filtered);
5828
+ return true;
5829
+ }
5830
+ /** Drop sessions whose `lastUsedAt` is older than `cutoff`. Returns the IDs removed. */
5831
+ pruneOlderThan(cutoff) {
5832
+ const sessions = this.load();
5833
+ const cutoffIso = cutoff.toISOString();
5834
+ const removed = sessions.filter((s) => s.lastUsedAt < cutoffIso).map((s) => s.id);
5835
+ if (removed.length === 0) return [];
5836
+ const kept = sessions.filter((s) => s.lastUsedAt >= cutoffIso);
5837
+ this.save(kept);
5838
+ return removed;
5839
+ }
5840
+ /** Drop every session. Returns the count removed. */
5841
+ clear() {
5842
+ const sessions = this.load();
5843
+ if (sessions.length === 0) return 0;
5844
+ this.save([]);
5845
+ return sessions.length;
5846
+ }
5758
5847
  upsert(opts) {
5848
+ if (opts.historyAppend !== void 0 && opts.replaceHistory !== void 0) {
5849
+ throw new Error("upsert: historyAppend and replaceHistory are mutually exclusive");
5850
+ }
5759
5851
  const sessions = this.load();
5760
5852
  const now = (/* @__PURE__ */ new Date()).toISOString();
5761
5853
  const existing = sessions.find((session2) => session2.id === opts.id);
@@ -5765,19 +5857,22 @@ var init_sessions = __esm({
5765
5857
  if (opts.backendSessionId) {
5766
5858
  existing.backendSessionId = opts.backendSessionId;
5767
5859
  }
5768
- if (opts.historyAppend?.length) {
5860
+ if (opts.replaceHistory !== void 0) {
5861
+ existing.history = [...opts.replaceHistory];
5862
+ } else if (opts.historyAppend?.length) {
5769
5863
  existing.history.push(...opts.historyAppend);
5770
5864
  }
5771
5865
  existing.lastUsedAt = now;
5772
5866
  this.save(sessions);
5773
5867
  return existing;
5774
5868
  }
5869
+ const initialHistory = opts.replaceHistory !== void 0 ? [...opts.replaceHistory] : [...opts.historyAppend ?? []];
5775
5870
  const session = {
5776
5871
  id: opts.id,
5777
5872
  backend: opts.backend,
5778
5873
  backendSessionId: opts.backendSessionId,
5779
5874
  repoPath: opts.repoPath,
5780
- history: [...opts.historyAppend ?? []],
5875
+ history: initialHistory,
5781
5876
  createdAt: now,
5782
5877
  lastUsedAt: now
5783
5878
  };
@@ -5954,6 +6049,7 @@ function prepareRelay(opts) {
5954
6049
  sandbox = DEFAULT_SANDBOX,
5955
6050
  schema = null,
5956
6051
  session = null,
6052
+ backendSession = null,
5957
6053
  fast = false
5958
6054
  } = opts;
5959
6055
  if (!prompt.trim()) {
@@ -5978,6 +6074,16 @@ function prepareRelay(opts) {
5978
6074
  const allowed = [...selectedBackend.allowedSandboxes].sort().join(", ");
5979
6075
  throw new RelayError(`Invalid sandbox mode: ${sandbox}. Allowed values: ${allowed}`);
5980
6076
  }
6077
+ if (backendSession && selectedBackend.capabilities.resumeStrategy !== "native-session") {
6078
+ throw new RelayError(
6079
+ `--backend-session is not supported by the ${selectedBackend.name} backend (resume strategy: ${selectedBackend.capabilities.resumeStrategy}).`
6080
+ );
6081
+ }
6082
+ if (session && selectedBackend.capabilities.resumeStrategy === "unsupported") {
6083
+ throw new RelayError(
6084
+ `--session is not supported by the ${selectedBackend.name} backend (resume strategy: unsupported). The backend cannot resume a prior conversation, so PaF refuses to persist a label that would silently fresh-spawn each call.`
6085
+ );
6086
+ }
5981
6087
  const resolvedContext = resolveContextText(contextFile, contextText);
5982
6088
  const diffText = includeDiff ? gitDiff(resolvedRepo) : "";
5983
6089
  const fullPrompt = buildPrompt({
@@ -5999,6 +6105,7 @@ function prepareRelay(opts) {
5999
6105
  model,
6000
6106
  schema,
6001
6107
  session,
6108
+ backendSession,
6002
6109
  fast,
6003
6110
  sessionStore: opts.sessionStore
6004
6111
  };
@@ -6014,10 +6121,62 @@ async function relay(opts) {
6014
6121
  model,
6015
6122
  schema,
6016
6123
  session,
6124
+ backendSession,
6017
6125
  fast,
6018
6126
  sessionStore
6019
6127
  } = prepareRelay(opts);
6020
6128
  try {
6129
+ if (backendSession) {
6130
+ const store2 = session ? sessionStore ?? new SessionStore() : null;
6131
+ const existing = session && store2 ? store2.get(session) : null;
6132
+ if (existing) {
6133
+ const conflicts = [];
6134
+ if (existing.backend !== selectedBackend.name) {
6135
+ conflicts.push(`backend "${existing.backend}" (expected "${selectedBackend.name}")`);
6136
+ }
6137
+ if (existing.backendSessionId && existing.backendSessionId !== backendSession) {
6138
+ conflicts.push(`backend session "${existing.backendSessionId}" (expected "${backendSession}")`);
6139
+ }
6140
+ if (existing.repoPath !== resolvedRepo) {
6141
+ conflicts.push(`repo "${existing.repoPath}" (expected "${resolvedRepo}")`);
6142
+ }
6143
+ if (conflicts.length > 0) {
6144
+ throw new RelayError(
6145
+ `Session label "${session}" already exists with conflicting metadata: ${conflicts.join("; ")}. Use a different label or remove the existing entry.`
6146
+ );
6147
+ }
6148
+ }
6149
+ let createdSessionId2 = backendSession;
6150
+ const result2 = await selectedBackend.run({
6151
+ prompt: fullPrompt,
6152
+ repoPath: resolvedRepo,
6153
+ timeoutSeconds,
6154
+ sandbox,
6155
+ model,
6156
+ env: env3,
6157
+ schema,
6158
+ sessionId: backendSession,
6159
+ persistSession: Boolean(session),
6160
+ resumeSession: true,
6161
+ fast,
6162
+ sessionHistory: existing?.history ?? [],
6163
+ onSessionCreated: (newSessionId) => {
6164
+ createdSessionId2 = newSessionId;
6165
+ }
6166
+ });
6167
+ if (session && store2) {
6168
+ persistRelaySession(
6169
+ store2,
6170
+ session,
6171
+ selectedBackend,
6172
+ resolvedRepo,
6173
+ fullPrompt,
6174
+ result2,
6175
+ createdSessionId2
6176
+ );
6177
+ }
6178
+ return result2;
6179
+ }
6021
6180
  const store = session ? sessionStore ?? new SessionStore() : null;
6022
6181
  const storedSession = session ? store?.get(session) ?? null : null;
6023
6182
  if (storedSession && storedSession.backend !== selectedBackend.name) {
@@ -6030,6 +6189,11 @@ async function relay(opts) {
6030
6189
  `Session ${session} belongs to a different repository: ${storedSession.repoPath}`
6031
6190
  );
6032
6191
  }
6192
+ if (session && !storedSession) {
6193
+ console.error(
6194
+ `[phone-a-friend] Session label "${session}" not found in store. Starting a fresh session under this label. If you meant to attach to an existing backend thread, use --backend-session <id>.`
6195
+ );
6196
+ }
6033
6197
  let backendSessionId = storedSession?.backendSessionId ?? null;
6034
6198
  if (session && !storedSession && selectedBackend.capabilities.requiresClientSessionId) {
6035
6199
  backendSessionId = randomUUID2();
@@ -6077,15 +6241,26 @@ async function relay(opts) {
6077
6241
  }
6078
6242
  }
6079
6243
  function persistRelaySession(store, id, backend, repoPath, prompt, output, backendSessionId) {
6244
+ const replaysHistory = backend.capabilities.resumeStrategy === "transcript-replay";
6245
+ if (replaysHistory) {
6246
+ store.upsert({
6247
+ id,
6248
+ backend: backend.name,
6249
+ repoPath,
6250
+ backendSessionId: backendSessionId ?? void 0,
6251
+ historyAppend: [
6252
+ { role: "user", content: prompt },
6253
+ { role: "assistant", content: output }
6254
+ ]
6255
+ });
6256
+ return;
6257
+ }
6080
6258
  store.upsert({
6081
6259
  id,
6082
6260
  backend: backend.name,
6083
6261
  repoPath,
6084
6262
  backendSessionId: backendSessionId ?? void 0,
6085
- historyAppend: [
6086
- { role: "user", content: prompt },
6087
- { role: "assistant", content: output }
6088
- ]
6263
+ replaceHistory: []
6089
6264
  });
6090
6265
  }
6091
6266
  async function* relayStream(opts) {
@@ -6099,11 +6274,12 @@ async function* relayStream(opts) {
6099
6274
  model,
6100
6275
  schema,
6101
6276
  session,
6277
+ backendSession,
6102
6278
  fast,
6103
6279
  sessionStore
6104
6280
  } = prepareRelay(opts);
6105
- const store = session ? sessionStore ?? new SessionStore() : null;
6106
- const storedSession = session ? store?.get(session) ?? null : null;
6281
+ const store = session && !backendSession ? sessionStore ?? new SessionStore() : null;
6282
+ const storedSession = session && !backendSession ? store?.get(session) ?? null : null;
6107
6283
  const runOpts = {
6108
6284
  prompt: fullPrompt,
6109
6285
  repoPath: resolvedRepo,
@@ -6113,9 +6289,9 @@ async function* relayStream(opts) {
6113
6289
  env: env3,
6114
6290
  schema,
6115
6291
  fast,
6116
- sessionId: storedSession?.backendSessionId ?? null,
6292
+ sessionId: backendSession ?? storedSession?.backendSessionId ?? null,
6117
6293
  persistSession: Boolean(session),
6118
- resumeSession: Boolean(session && storedSession),
6294
+ resumeSession: Boolean(backendSession || session && storedSession),
6119
6295
  sessionHistory: storedSession?.history ?? []
6120
6296
  };
6121
6297
  try {
@@ -6287,7 +6463,7 @@ import {
6287
6463
  rmSync as rmSync2,
6288
6464
  symlinkSync,
6289
6465
  cpSync,
6290
- unlinkSync
6466
+ unlinkSync as unlinkSync2
6291
6467
  } from "fs";
6292
6468
  import { resolve as resolve3, join as join5, dirname as dirname5 } from "path";
6293
6469
  import { homedir as homedir4 } from "os";
@@ -6303,7 +6479,7 @@ function removePath(filePath) {
6303
6479
  throw err;
6304
6480
  }
6305
6481
  if (stat.isSymbolicLink() || stat.isFile()) {
6306
- unlinkSync(filePath);
6482
+ unlinkSync2(filePath);
6307
6483
  } else if (stat.isDirectory()) {
6308
6484
  rmSync2(filePath, { recursive: true, force: true });
6309
6485
  }
@@ -17884,7 +18060,7 @@ var init_parse_editor_command = __esm({
17884
18060
 
17885
18061
  // node_modules/@inquirer/external-editor/dist/index.js
17886
18062
  import { spawn as spawn3, spawnSync } from "child_process";
17887
- import { readFileSync as readFileSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
18063
+ import { readFileSync as readFileSync8, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
17888
18064
  import path2 from "path";
17889
18065
  import os2 from "os";
17890
18066
  import { randomUUID as randomUUID3 } from "crypto";
@@ -17959,7 +18135,7 @@ var init_dist8 = __esm({
17959
18135
  if (!this.tempFile)
17960
18136
  return;
17961
18137
  try {
17962
- unlinkSync2(this.tempFile);
18138
+ unlinkSync3(this.tempFile);
17963
18139
  this.tempFile = "";
17964
18140
  } catch (removeFileError) {
17965
18141
  throw new RemoveFileError(removeFileError);
@@ -76642,8 +76818,14 @@ var GeminiBackend = class {
76642
76818
  "workspace-write",
76643
76819
  "danger-full-access"
76644
76820
  ]);
76821
+ // Session resume is declared 'unsupported' rather than 'transcript-replay':
76822
+ // run() never reads opts.sessionHistory, and the --resume code path below
76823
+ // depends on a session ID that the upstream extractor cannot reliably
76824
+ // produce (see extractGeminiSessionId). Until the Gemini CLI's session
76825
+ // surface is verified, --session against this backend is rejected at the
76826
+ // relay layer instead of silently no-opping.
76645
76827
  capabilities = {
76646
- resumeStrategy: "transcript-replay",
76828
+ resumeStrategy: "unsupported",
76647
76829
  requiresClientSessionId: false
76648
76830
  };
76649
76831
  async run(opts) {
@@ -80349,7 +80531,7 @@ init_version();
80349
80531
  function repoRootDefault() {
80350
80532
  return getPackageRoot();
80351
80533
  }
80352
- var KNOWN_SUBCOMMANDS = ["relay", "install", "update", "uninstall", "setup", "doctor", "config", "plugin", "agentic", "job"];
80534
+ var KNOWN_SUBCOMMANDS = ["relay", "install", "update", "uninstall", "setup", "doctor", "config", "plugin", "agentic", "job", "session"];
80353
80535
  var TOP_LEVEL_FLAGS = /* @__PURE__ */ new Set(["-v", "-V", "--version", "-h", "--help"]);
80354
80536
  function normalizeArgv(argv) {
80355
80537
  if (argv.length === 0) return argv;
@@ -80545,7 +80727,7 @@ ${banner("AI coding agent relay")}
80545
80727
  writeOut: (str) => console.log(str.trimEnd()),
80546
80728
  writeErr: (str) => console.error(str.trimEnd())
80547
80729
  }).exitOverride();
80548
- program2.command("relay").description("Relay prompt/context to a coding backend (default)").option("--prompt <text>", "Prompt to relay (required unless --review or --base is used)").option("--to <backend>", "Target backend: codex, gemini, ollama, claude, opencode").option("--repo <path>", "Repository path", process.cwd()).option("--context-file <path>", "File with additional context").option("--context-text <text>", "Inline context text").option("--include-diff", "Append git diff to prompt").option("--timeout <seconds>", "Max runtime in seconds").option("--model <name>", "Model override").option("--sandbox <mode>", "Sandbox: read-only, workspace-write, danger-full-access").option("--schema <json>", "Request structured JSON output matching this schema").option("--session <id>", "Resume or create a persisted relay session").option("--fast", "Use fast mode when supported (maps to --bare for Claude)").option("--stream", "Stream tokens as they arrive (default)").option("--no-stream", "Disable streaming output (get full response at once)").option("--review", "Use review mode (scoped to diff against base branch)").option("--base <branch>", "Base branch for review diff (default: auto-detect main/master)").option("--quiet", "Run silently, save result to job store").action(async (opts, command) => {
80730
+ program2.command("relay").description("Relay prompt/context to a coding backend (default)").option("--prompt <text>", "Prompt to relay (required unless --review or --base is used)").option("--to <backend>", "Target backend: codex, gemini, ollama, claude, opencode").option("--repo <path>", "Repository path", process.cwd()).option("--context-file <path>", "File with additional context").option("--context-text <text>", "Inline context text").option("--include-diff", "Append git diff to prompt").option("--timeout <seconds>", "Max runtime in seconds").option("--model <name>", "Model override").option("--sandbox <mode>", "Sandbox: read-only, workspace-write, danger-full-access").option("--schema <json>", "Request structured JSON output matching this schema").option("--session <id>", "Resume or create a persisted relay session (PaF label)").option("--backend-session <id>", "Attach to a raw backend session/thread ID (bypasses PaF label store; combine with --session to adopt it)").option("--fast", "Use fast mode when supported (maps to --bare for Claude)").option("--stream", "Stream tokens as they arrive (default)").option("--no-stream", "Disable streaming output (get full response at once)").option("--review", "Use review mode (scoped to diff against base branch)").option("--base <branch>", "Base branch for review diff (default: auto-detect main/master)").option("--quiet", "Run silently, save result to job store").action(async (opts, command) => {
80549
80731
  const isReview = opts.review || opts.base !== void 0;
80550
80732
  if (!opts.prompt && !isReview) {
80551
80733
  console.error(` ${theme.crossmark} ${theme.error("--prompt is required (unless using --review or --base)")}`);
@@ -80603,9 +80785,10 @@ ${banner("AI coding agent relay")}
80603
80785
  sandbox: resolved.sandbox,
80604
80786
  schema: opts.schema ?? null,
80605
80787
  session: opts.session ?? null,
80788
+ backendSession: opts.backendSession ?? null,
80606
80789
  fast: Boolean(opts.fast)
80607
80790
  };
80608
- const shouldStream = resolved.stream && !opts.schema && !opts.session;
80791
+ const shouldStream = resolved.stream && !opts.schema && !opts.session && !opts.backendSession;
80609
80792
  if (opts.quiet) {
80610
80793
  const { relayBackground: relayBackground2 } = await Promise.resolve().then(() => (init_relay(), relay_exports));
80611
80794
  const { JobManager: JobManager2 } = await Promise.resolve().then(() => (init_jobs(), jobs_exports));
@@ -80968,6 +81151,71 @@ ${banner("AI coding agent relay")}
80968
81151
  manager.update(id, { status: "cancelled" });
80969
81152
  console.log(` ${theme.success("\u2713")} Cancelled job ${theme.bold(id)}`);
80970
81153
  });
81154
+ const sessionCmd = program2.command("session").description("Manage persisted relay sessions");
81155
+ sessionCmd.command("list").description("List persisted relay sessions").option("--json", "Output as JSON", false).action(async (opts) => {
81156
+ const { SessionStore: SessionStore2 } = await Promise.resolve().then(() => (init_sessions(), sessions_exports));
81157
+ const store = new SessionStore2();
81158
+ const sessions = store.list();
81159
+ if (opts.json) {
81160
+ console.log(JSON.stringify(sessions, null, 2));
81161
+ return;
81162
+ }
81163
+ if (sessions.length === 0) {
81164
+ console.log(`
81165
+ ${theme.hint("No persisted sessions.")}
81166
+ `);
81167
+ return;
81168
+ }
81169
+ console.log(`
81170
+ ${theme.heading("Persisted Sessions")} ${theme.hint(`(${sessions.length})`)}
81171
+ `);
81172
+ const sorted = [...sessions].sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt));
81173
+ for (const session of sorted) {
81174
+ const age = timeSince(session.lastUsedAt);
81175
+ const backendSid = session.backendSessionId ?? theme.hint("(none)");
81176
+ console.log(` ${theme.bold(session.id)} ${theme.info(session.backend)} ${theme.hint(age)}`);
81177
+ console.log(` ${theme.hint("backend session:")} ${backendSid}`);
81178
+ console.log(` ${theme.hint("repo:")} ${session.repoPath}`);
81179
+ console.log(` ${theme.hint("history:")} ${session.history.length} entries`);
81180
+ }
81181
+ console.log("");
81182
+ });
81183
+ sessionCmd.command("delete <label>").description("Remove a persisted session by label").action(async (label) => {
81184
+ const { SessionStore: SessionStore2 } = await Promise.resolve().then(() => (init_sessions(), sessions_exports));
81185
+ const store = new SessionStore2();
81186
+ const removed = store.delete(label);
81187
+ if (!removed) {
81188
+ console.error(` ${theme.crossmark} Session ${theme.bold(label)} not found`);
81189
+ exitCode = 1;
81190
+ return;
81191
+ }
81192
+ console.log(` ${theme.success("\u2713")} Deleted session ${theme.bold(label)}`);
81193
+ });
81194
+ sessionCmd.command("prune").description("Remove old sessions (default: older than 30 days)").option("--older-than <days>", "Drop sessions whose lastUsedAt is older than N days", "30").option("--all", "Drop every session", false).action(async (opts) => {
81195
+ const { SessionStore: SessionStore2 } = await Promise.resolve().then(() => (init_sessions(), sessions_exports));
81196
+ const store = new SessionStore2();
81197
+ if (opts.all) {
81198
+ const count = store.clear();
81199
+ console.log(` ${theme.success("\u2713")} Removed ${theme.bold(String(count))} session${count === 1 ? "" : "s"}`);
81200
+ return;
81201
+ }
81202
+ const days = Number(opts.olderThan);
81203
+ if (!Number.isFinite(days) || days <= 0) {
81204
+ console.error(` ${theme.crossmark} --older-than must be a positive number of days, got "${opts.olderThan}"`);
81205
+ exitCode = 1;
81206
+ return;
81207
+ }
81208
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
81209
+ const removed = store.pruneOlderThan(cutoff);
81210
+ if (removed.length === 0) {
81211
+ console.log(` ${theme.hint(`No sessions older than ${days} day${days === 1 ? "" : "s"}.`)}`);
81212
+ return;
81213
+ }
81214
+ console.log(` ${theme.success("\u2713")} Pruned ${theme.bold(String(removed.length))} session${removed.length === 1 ? "" : "s"} older than ${days} day${days === 1 ? "" : "s"}`);
81215
+ for (const id of removed) {
81216
+ console.log(` ${theme.hint("-")} ${id}`);
81217
+ }
81218
+ });
80971
81219
  addInstallOptions(
80972
81220
  program2.command("install").description("Install Claude plugin (alias for: plugin install)")
80973
81221
  ).action((opts) => installAction(opts));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freibergergarcia/phone-a-friend",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "CLI relay that lets AI coding agents collaborate",
5
5
  "keywords": [
6
6
  "ai-agent",