@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.
- package/.claude-plugin/plugin.json +1 -1
- package/commands/phone-a-friend.md +37 -10
- package/commands/phone-a-team.md +38 -15
- package/dist/index.js +271 -23
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
156
|
-
deltas only.
|
|
157
|
-
- **Ollama**: replays full history each call. Sessions work but prompt
|
|
158
|
-
grows with each turn. Keep follow-ups concise.
|
|
159
|
-
- **Gemini**: session
|
|
160
|
-
|
|
161
|
-
|
|
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
|
package/commands/phone-a-team.md
CHANGED
|
@@ -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
|
|
237
|
-
|
|
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)
|
|
396
|
-
`--session`
|
|
397
|
-
|
|
398
|
-
|
|
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 (
|
|
515
|
-
|
|
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:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5750
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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));
|