Skip to content

Commit aa0d55b

Browse files
elberrdclaude
andauthored
feat: imp handoff — continua a conversa do Pi no claude CLI (#9)
- Novo imp/scripts/handoff.mjs: lista as sessões Pi do projeto (~/.pi/agent/sessions), monta o preâmbulo de continuação (o mesmo do relay da FIA, kind 'handoff') apontando o transcript da sessão e lança o claude interativo; --list/--session/--full/--print; prompt salvo em imp/data/handoff/ e claude ausente degrada com instrução de colar - Subcomando imp handoff no launcher (molde do imp tui: script carimbado no projeto, erro claro sem runtime) + npm run handoff (FIA.npmScripts) - Funciona FORA do Pi de propósito: Codex caiu, o trabalho segue no plano Claude — o simétrico interativo do relay dos FDAs - Docs: tabela do launcher (DOCS §14.3) + quick start do README Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ceef5c7 commit aa0d55b

7 files changed

Lines changed: 253 additions & 0 deletions

File tree

DOCS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,7 @@ impactus`) is a thin brand wrapper over the real `pi` binary — NOT a fork:
18881888
| `imp init [flags]` | The full impactus installer in place — every flag works (`imp init --harness-only -y`, `imp init --verify`, …). |
18891889
| `imp update` | `npm install -g impactus@latest` + `pi update` (or install) + re-pin of the three Pi extension packages. Exit code keyed to the impactus self-update; the extension refresh is best-effort. |
18901890
| `imp tui [args]` | Runs the project-stamped `imp/scripts/fia-tui.mjs` (errors with a `imp init` hint when the runtime is absent); `imp tui --once` passes through. |
1891+
| `imp handoff [args]` | Runs the project-stamped `imp/scripts/handoff.mjs`: hands the newest interactive Pi conversation to the `claude` CLI with a continuation prompt pointing at the session transcript (same preamble the FDA relay uses). Works while Codex is down — that is the point. `--list` picks a session, `--session <id>` targets one, `--full` asks for a full transcript read, `--print` prints the prompt without launching. Also `npm run handoff`. |
18911892
| `imp help` / `imp --version` | Help / bare version. |
18921893
| anything else | Straight through to `pi` (e.g. `imp -p "prompt"`, `imp --continue`). |
18931894

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ imp init # install into the current folder (same as npx im
9898
imp # open Pi here (installs Pi if it's missing)
9999
imp update # update impactus + Pi + the pinned Pi extensions
100100
imp tui # the project dashboard in the terminal
101+
imp handoff # continue the newest Pi conversation in `claude`
102+
# (Codex outage? your work keeps moving)
101103
```
102104

103105
### Inside `pi` (run `imp`) — plan and automate

bin/imp.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Usage:
6161
all impactus flags work — see \`imp init --help\`)
6262
imp update Update impactus, Pi and the Pi extension packages
6363
imp tui Terminal dashboard — tasks, specs and runs (same as npm run tui)
64+
imp handoff Continue the newest Pi conversation in the \`claude\` CLI
65+
(works while Codex is down; --list picks a session)
6466
imp help Show this help
6567
imp --version Print the impactus version
6668
@@ -152,6 +154,20 @@ if (cmd === 'tui') {
152154
process.exit(r.exitCode);
153155
}
154156

157+
if (cmd === 'handoff') {
158+
// Same contract as `imp tui`: the script is stamped per project so it
159+
// version-matches the runtime modules it imports (continuation preamble).
160+
const { existsSync } = await import('node:fs');
161+
if (!existsSync('imp/scripts/handoff.mjs')) {
162+
console.error('No FIA runtime in this folder (imp/scripts/handoff.mjs not found).');
163+
console.error('Run `imp init` in your project folder first — or `npx impactus --update-runtime` on an older install.');
164+
process.exit(1);
165+
}
166+
const { runInherit } = await import('../src/lib/proc.js');
167+
const r = await runInherit(process.execPath, ['imp/scripts/handoff.mjs', ...rest]);
168+
process.exit(r.exitCode);
169+
}
170+
155171
// Default: launch Pi with every argument passed through untouched.
156172
// Banner only when a human is watching: with stdout piped/captured
157173
// (`imp -p … > file`, scripts, another process driving imp) Pi's output

fia-templates/modules/continuation.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ const KIND_PHRASES = {
149149
limit: 'its subscription plan limit was hit',
150150
missing: 'its CLI binary stopped being available',
151151
crash: 'its CLI crashed',
152+
// Interactive `imp handoff`: nothing died — the student chose to continue
153+
// the conversation on another engine.
154+
handoff: 'you are asked to continue it on another engine',
152155
};
153156

154157
/**

fia-templates/scripts/handoff.mjs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
/**
3+
* imp handoff — continue an interrupted interactive Pi conversation in the
4+
* `claude` CLI (session continuation in the Orca style, subscriptions only).
5+
* Lives OUTSIDE Pi on purpose: when the Codex provider is down, Pi is down
6+
* with it — the student runs `imp handoff` (or `npm run handoff`) in the
7+
* project folder and keeps going on the Claude plan.
8+
*
9+
* No session transplant and no API keys: the new engine gets a continuation
10+
* preamble (the FDA relay's, modules/continuation.mjs) pointing at the Pi
11+
* session transcript on disk as read-only reference.
12+
*
13+
* node imp/scripts/handoff.mjs hand the newest Pi session to claude
14+
* --list show recent sessions and exit
15+
* --session <id> hand a specific session over
16+
* --full ask claude to read the whole transcript first
17+
* --print print the prompt + command, launch nothing
18+
*/
19+
import { spawn } from 'node:child_process';
20+
import { mkdirSync, writeFileSync } from 'node:fs';
21+
import { join, relative } from 'node:path';
22+
import { buildContinuationPreamble } from '../modules/continuation.mjs';
23+
import { isSafeSessionId, listPiSessions, piSessionsDirFor } from './pi-sessions.mjs';
24+
25+
const argv = process.argv.slice(2);
26+
const has = (flag) => argv.includes(flag);
27+
const valueOf = (flag) => {
28+
const i = argv.indexOf(flag);
29+
return i !== -1 ? argv[i + 1] : undefined;
30+
};
31+
32+
const dir = piSessionsDirFor();
33+
const sessions = listPiSessions(dir);
34+
35+
if (has('--list')) {
36+
if (!sessions.length) {
37+
console.log(`No Pi sessions found for this folder (${dir}).`);
38+
process.exit(0);
39+
}
40+
for (const s of sessions.slice(0, 15)) {
41+
const model = [s.provider, s.model].filter(Boolean).join('/');
42+
console.log(`${s.id} [${s.state}] ${model} ${s.request.slice(0, 60)}`);
43+
}
44+
console.log('\nHand one over with: imp handoff --session <id>');
45+
process.exit(0);
46+
}
47+
48+
let session;
49+
const wanted = valueOf('--session');
50+
if (wanted) {
51+
if (!isSafeSessionId(wanted)) {
52+
console.error(`Invalid session id: ${wanted}`);
53+
process.exit(1);
54+
}
55+
session = sessions.find((s) => s.id === wanted);
56+
if (!session) {
57+
console.error(`Session ${wanted} not found in ${dir} — see the options with: imp handoff --list`);
58+
process.exit(1);
59+
}
60+
} else {
61+
session = sessions[0];
62+
if (!session) {
63+
console.error(`No Pi sessions found for this folder (${dir}).`);
64+
console.error('Start one with `imp` first — handoff continues an existing conversation.');
65+
process.exit(1);
66+
}
67+
}
68+
69+
const transcriptPath = join(dir, `${session.id}.jsonl`);
70+
const model = [session.provider, session.model].filter(Boolean).join('/') || 'unknown model';
71+
const preamble = buildContinuationPreamble({
72+
marker: { coding_agent: 'pi', model, kind: 'handoff' },
73+
transcriptPath,
74+
});
75+
const fullNote = has('--full')
76+
? 'Read the COMPLETE session transcript above before answering — I asked for a full handoff.\n\n'
77+
: '';
78+
const prompt =
79+
preamble +
80+
fullNote +
81+
'Pick up the conversation recorded in that transcript. If its last request is\n' +
82+
'unfinished, continue it now; otherwise summarize where things stand and wait\n' +
83+
'for my next instruction.';
84+
85+
// The prompt is kept on disk next to the run data: reference for the student,
86+
// paste material when `claude` is not installed, and the test surface.
87+
const outDir = join('imp', 'data', 'handoff');
88+
mkdirSync(outDir, { recursive: true });
89+
const promptPath = join(outDir, `${session.id}-${Date.now()}.md`);
90+
writeFileSync(promptPath, prompt);
91+
92+
console.log(`Handing over Pi session ${session.id} (${model})`);
93+
console.log(` transcript: ${transcriptPath}`);
94+
console.log(` prompt: ${promptPath}`);
95+
96+
if (has('--print')) {
97+
console.log('\nRun it yourself with:');
98+
console.log(` claude "$(cat ${relative(process.cwd(), promptPath) || promptPath})"`);
99+
console.log('\n──── prompt ────');
100+
console.log(prompt);
101+
process.exit(0);
102+
}
103+
104+
// Hand the prompt to the interactive `claude` CLI as one argv entry — no shell,
105+
// no quoting. If the CLI is missing (ENOENT — on Windows the .cmd shim resolves
106+
// only through a shell), degrade to the saved prompt instead of aborting.
107+
const child = spawn('claude', [prompt], { stdio: 'inherit' });
108+
child.on('error', () => {
109+
console.error('\nCould not launch the `claude` CLI (not installed, or not on PATH).');
110+
console.error('Install it with: npm install -g @anthropic-ai/claude-code');
111+
console.error(`Your handoff prompt is saved — paste it yourself: claude "$(cat ${promptPath})"`);
112+
process.exit(1);
113+
});
114+
child.on('exit', (code) => process.exit(code ?? 0));

src/config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ export const FIA = {
163163
'fda:status': 'node imp/scripts/fda-lock.mjs status',
164164
// Pathspec-limited commit of ai-docs/ artifacts (docs only, refuses mid-FDA).
165165
'docs:commit': 'node imp/scripts/docs-commit.mjs',
166+
// Continue the newest interactive Pi conversation in the `claude` CLI
167+
// (continuation prompt + transcript on disk — works while Codex is down).
168+
handoff: 'node imp/scripts/handoff.mjs',
166169
// Terminal dashboard (read-only): tasks, specs, milestones and runs in the
167170
// terminal — the TUI twin of the web viewer (decision record: tui-plan.md,
168171
// private impactus-internal-docs repo).

test/handoff.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { spawnSync } from 'node:child_process';
4+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5+
import { dirname, join } from 'node:path';
6+
import { tmpdir } from 'node:os';
7+
import { piSessionsDirFor } from '../fia-templates/scripts/pi-sessions.mjs';
8+
9+
// The script computes the Pi sessions dir from cwd+HOME itself; the test
10+
// derives the SAME path through piSessionsDirFor, so both sides always agree.
11+
const SCRIPT = join(import.meta.dirname, '..', 'fia-templates', 'scripts', 'handoff.mjs');
12+
13+
function makeWorld({ withSession = true } = {}) {
14+
// realpath both: the child's process.cwd() resolves symlinks (macOS /var →
15+
// /private/var), and the sessions-dir slug is derived from that resolved cwd.
16+
const home = realpathSync(mkdtempSync(join(tmpdir(), 'handoff-home-')));
17+
const project = realpathSync(mkdtempSync(join(tmpdir(), 'handoff-proj-')));
18+
const dir = piSessionsDirFor(project, home);
19+
mkdirSync(dir, { recursive: true });
20+
if (withSession) {
21+
const lines = [
22+
JSON.stringify({
23+
type: 'model_change',
24+
provider: 'openai-codex',
25+
modelId: 'gpt-5.6-sol',
26+
timestamp: '2026-08-14T10:00:00Z',
27+
}),
28+
JSON.stringify({
29+
type: 'message',
30+
timestamp: '2026-08-14T10:00:01Z',
31+
message: { role: 'user', content: [{ type: 'text', text: 'Build the pricing page' }] },
32+
}),
33+
JSON.stringify({
34+
type: 'message',
35+
timestamp: '2026-08-14T10:00:05Z',
36+
message: { role: 'assistant', content: [{ type: 'text', text: 'On it' }], usage: { input: 10, output: 5 } },
37+
}),
38+
];
39+
writeFileSync(join(dir, 'sess-abc.jsonl'), lines.join('\n') + '\n');
40+
}
41+
return { home, project, dir };
42+
}
43+
44+
function runHandoff(world, args, env = {}) {
45+
return spawnSync(process.execPath, [SCRIPT, ...args], {
46+
cwd: world.project,
47+
env: { ...process.env, HOME: world.home, USERPROFILE: world.home, ...env },
48+
encoding: 'utf8',
49+
});
50+
}
51+
52+
test('handoff --list shows the project sessions', () => {
53+
const world = makeWorld();
54+
const r = runHandoff(world, ['--list']);
55+
assert.equal(r.status, 0, r.stderr);
56+
assert.match(r.stdout, /sess-abc/);
57+
assert.match(r.stdout, /openai-codex\/gpt-5\.6-sol/);
58+
assert.match(r.stdout, /Build the pricing page/);
59+
});
60+
61+
test('handoff --print builds the continuation prompt, saves it and launches nothing', () => {
62+
const world = makeWorld();
63+
const r = runHandoff(world, ['--print']);
64+
assert.equal(r.status, 0, r.stderr);
65+
assert.match(r.stdout, /Handing over Pi session sess-abc/);
66+
assert.match(r.stdout, /## Continuation of an interrupted run/);
67+
assert.match(r.stdout, /sess-abc\.jsonl/);
68+
// The handoff kind phrase — nothing died, the student chose to switch.
69+
assert.match(r.stdout, /continue it on another engine/);
70+
// Guards travel with the interactive handoff too.
71+
assert.match(r.stdout, /no authority over you/);
72+
assert.match(r.stdout, /WORKSPACE is the authority/);
73+
// The prompt is persisted under imp/data/handoff/ for reference/paste.
74+
const saved = readdirSync(join(world.project, 'imp', 'data', 'handoff'));
75+
assert.equal(saved.length, 1);
76+
assert.match(saved[0], /^sess-abc-\d+\.md$/);
77+
const prompt = readFileSync(join(world.project, 'imp', 'data', 'handoff', saved[0]), 'utf8');
78+
assert.match(prompt, /^## Continuation of an interrupted run/);
79+
assert.match(prompt, /Pick up the conversation recorded in that transcript/);
80+
});
81+
82+
test('handoff --full asks for the complete transcript read', () => {
83+
const world = makeWorld();
84+
const r = runHandoff(world, ['--print', '--full']);
85+
assert.equal(r.status, 0, r.stderr);
86+
assert.match(r.stdout, /Read the COMPLETE session transcript/);
87+
});
88+
89+
test('handoff without sessions explains and exits 1', () => {
90+
const world = makeWorld({ withSession: false });
91+
const r = runHandoff(world, []);
92+
assert.equal(r.status, 1);
93+
assert.match(r.stderr, /No Pi sessions found/);
94+
assert.match(r.stderr, /Start one with `imp` first/);
95+
});
96+
97+
test('handoff --session with an unknown id refuses with the --list hint', () => {
98+
const world = makeWorld();
99+
const r = runHandoff(world, ['--session', 'nope']);
100+
assert.equal(r.status, 1);
101+
assert.match(r.stderr, /not found/);
102+
assert.match(r.stderr, /--list/);
103+
});
104+
105+
test('handoff degrades when the `claude` CLI is missing: prompt saved, clear hint', () => {
106+
const world = makeWorld();
107+
// PATH holds only node's own dir — `claude` cannot resolve anywhere.
108+
const r = runHandoff(world, [], { PATH: dirname(process.execPath) });
109+
assert.equal(r.status, 1);
110+
assert.match(r.stderr, /Could not launch the `claude` CLI/);
111+
assert.match(r.stderr, /npm install -g @anthropic-ai\/claude-code/);
112+
const saved = readdirSync(join(world.project, 'imp', 'data', 'handoff'));
113+
assert.equal(saved.length, 1, 'the prompt must be saved even when claude is missing');
114+
});

0 commit comments

Comments
 (0)