feat(pi): auto-title Pi sessions via bundled hapi_change_title extension - #1719
Conversation
Pi sessions never got automatic titles: HAPI set PI_RPC_EMIT_TITLE=1 but Pi does not implement it, and unlike the Claude/Codex/OpenCode launchers the Pi bridge neither registers a change_title tool nor injects a title instruction. This materializes a bundled Pi extension at launch and passes it via --extension, giving Pi sessions the same titling flow: - hapi_change_title tool (namespaced to avoid collisions with user extensions, mirroring the OpenCode launcher naming) - first-turn system-prompt instruction matching the Claude/Codex wording - title lands via ctx.ui.setTitle(), which the existing extension UI bridge already syncs into session metadata Removes the dead PI_RPC_EMIT_TITLE env var. Requires pi >= 0.35.0 (when --extension landed, 2026-01). Closes tiann#1669 by giving sessions titles from the session model itself, with zero extra title-provider calls.
There was a problem hiding this comment.
Findings
-
[Major] Publish the generated extension atomically — every Pi launch rewrites the same versioned file. A concurrent launcher can truncate it while another Pi process imports it, and Pi treats extension-load errors as fatal. Evidence:
cli/src/pi/titleExtension.ts:77.
Suggested fix:const temporaryFile = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporaryFile, PI_TITLE_EXTENSION_SOURCE, 'utf8'); try { await rename(temporaryFile, file); } catch (error) { if (!(await fileExists(file))) throw error; await unlink(temporaryFile).catch(() => {}); }
-
[Minor] Preserve the objective-change rename rule after the first title —
before_agent_startstops injecting the instruction oncetitledbecomes true, so later turns are no longer told to rename when the primary objective changes substantially. This differs from the persistent Claude/Codex instruction this code is intended to match. Evidence:cli/src/pi/titleExtension.ts:40.
Suggested fix:pi.on('before_agent_start', function (event) { return { systemPrompt: event.systemPrompt + TITLE_INSTRUCTION }; });
Summary
Review mode: initial
Two issues found: a concurrent-launch startup race and loss of the documented retitle behavior after the first title.
Testing
Not run (automation; PR code was not executed). CI integration is passing; the main test job is still pending. Add a concurrent materialization test and a handler test covering a second turn after the title tool executes.
HAPI Bot
| const dir = targetDir ?? join(configuration.happyHomeDir, 'runtime', packageJson.version, 'pi'); | ||
| const file = join(dir, 'hapi-title-extension.ts'); | ||
| await mkdir(dir, { recursive: true }); | ||
| await writeFile(file, PI_TITLE_EXTENSION_SOURCE, 'utf8'); |
There was a problem hiding this comment.
[MAJOR] Publish this file atomically. All Pi sessions for a HAPI version share this path, and every launch opens it with writeFile's truncating w mode. A second launcher can truncate/rewrite it while the first Pi child is importing the extension; Pi treats an extension-load error as fatal, so an otherwise valid concurrent session can fail to start.
Suggested fix:
const temporaryFile = `${file}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryFile, PI_TITLE_EXTENSION_SOURCE, 'utf8');
try {
await rename(temporaryFile, file);
} catch (error) {
if (!(await fileExists(file))) throw error;
await unlink(temporaryFile).catch(() => {});
}A content-addressed immutable filename with atomic publication would also avoid the race.
| // launcher convention) so user extensions registering their own tools | ||
| // cannot collide with it. | ||
| pi.on('before_agent_start', function (event) { | ||
| if (titled) return; |
There was a problem hiding this comment.
[MINOR] Keep the update rule in later turns. After the first successful tool call sets titled = true, this handler returns no override, so the model loses the instruction to rename when the user's primary objective changes substantially. The tool description only discusses the initial title, so later turns have no equivalent guidance.
Suggested fix:
pi.on('before_agent_start', function (event) {
return { systemPrompt: event.systemPrompt + TITLE_INSTRUCTION };
});Then remove the titled flag/assignment; the instruction itself already says not to rename for routine progress.
Review findings from the HAPI PR bot: - [Major] Publish the generated extension atomically. All Pi sessions of a HAPI version share the same versioned path and Pi treats extension-load errors as fatal, so a plain writeFile could break a concurrent launch with a half-written file. The extension is now written to a unique temp file and moved into place with rename; if rename loses a race against another launcher that already published the file, the existing copy is kept and the temp file is cleaned up. - [Minor] Inject the title instruction on every before_agent_start instead of stopping after the first title, preserving the objective-change retitle rule from the persistent Claude/Codex instruction. Adds a concurrent-materialization test and a handler test that executes the title tool and asserts a later turn still carries the instruction.
|
Both findings are addressed in ec6d279: [Major] atomic publication — [Minor] persistent instruction — the Tests added per the review's request:
Full suite: 63 passed, |
There was a problem hiding this comment.
Findings
- [Major] Keep the generated extension compatible with Pi versions HAPI still accepts — the source uses the new
typeboxmodule name, new toolexecuteargument order, and ahasUIguard that is false in older RPC releases. HAPI has no newer Pi minimum-version check or documented floor (Not found in repo/docs), so those installations either fail to load the extension or execute it without setting a title. Evidencecli/src/pi/titleExtension.ts:21.
Suggested fix:import { Type } from '@sinclair/typebox'; async execute(_toolCallId, params, _third, fourth, fifth) { const ctx = fifth && typeof fifth === 'object' && 'ui' in fifth ? fifth : fourth; const title = String((params && params.title) || '').replace(/\s+/g, ' ').trim().slice(0, 80); if (!title) { return { content: [{ type: 'text', text: 'Error: title must not be empty' }], details: {} }; } ctx.ui.setTitle(title); return { content: [{ type: 'text', text: 'Session title set: ' + title }], details: {} }; }
Summary
Review mode: follow-up after new commits
The earlier atomic-publication and persistent-instruction findings are resolved. One Major compatibility regression remains: older Pi releases HAPI still accepts cannot load or execute the bundled extension correctly.
Testing
Not run (automation; PR code was not executed). Current test and integration checks pass. Add extension tests for both legacy (id, params, onUpdate, ctx, signal) and current (id, params, signal, onUpdate, ctx) call orders, including RPC hasUI: false.
HAPI Bot
| * child process. | ||
| */ | ||
| export const PI_TITLE_EXTENSION_SOURCE = `// Generated by HAPI - auto session titling for Pi sessions. | ||
| import { Type } from 'typebox'; |
There was a problem hiding this comment.
[MAJOR] Preserve compatibility with Pi versions HAPI does not reject. Older extension loaders expose TypeBox as @sinclair/typebox; their tool callback order is (id, params, onUpdate, ctx, signal), and RPC reports hasUI: false even though ctx.ui.setTitle() emits the title event. This new source assumes all three newer behaviors, so an otherwise accepted older Pi either fails during extension loading or reaches the tool with the wrong ctx/skips setTitle. Use the backward-compatible alias and normalize the context position; call setTitle directly because this extension is only launched in RPC mode.
Suggested fix:
import { Type } from '@sinclair/typebox';
async execute(_toolCallId, params, _third, fourth, fifth) {
const ctx = fifth && typeof fifth === 'object' && 'ui' in fifth ? fifth : fourth;
const title = String((params && params.title) || '').replace(/\s+/g, ' ').trim().slice(0, 80);
if (!title) {
return { content: [{ type: 'text', text: 'Error: title must not be empty' }], details: {} };
}
ctx.ui.setTitle(title);
return { content: [{ type: 'text', text: 'Session title set: ' + title }], details: {} };
}Follow-up review finding: the embedded extension assumed three behaviors that only exist in recent Pi releases, while HAPI has no Pi minimum-version floor, so accepted older installations either failed to load the extension or reached the tool without setting a title: - import TypeBox via '@sinclair/typebox', the specifier both the legacy extension loaders and the current one (which aliases it to the bundled 'typebox') resolve; - normalize the execute() context positionally: current Pi passes (toolCallId, params, signal, onUpdate, ctx), legacy releases passed (toolCallId, params, onUpdate, ctx, signal); - call ctx.ui.setTitle() directly instead of gating on ctx.hasUI, which older RPC releases report as false even though setTitle emits the extension UI event (this extension only runs under HAPI's RPC bridge). Tests now exercise both call orders, including hasUI: false.
|
Addressed in eb5d0f0 — the extension source is now backward-compatible with the Pi releases HAPI accepts:
Tests added per the review request: both call orders are exercised, including |
There was a problem hiding this comment.
Findings
No findings.
Summary
Review mode: follow-up after new commits
No issues remain on the current head. The previous Pi compatibility finding is no longer present. Residual risk: the materialized extension test substitutes the TypeBox import rather than loading the file through an actual packaged Pi extension loader.
Testing
Not run (automation; PR code was not executed). The integration check passed; the test check was still pending at review time. If practical, add a packaged-loader integration test that loads the materialized extension without replacing its import.
HAPI Bot
Problem
Pi sessions in HAPI never get automatic titles:
PI_RPC_EMIT_TITLE=1, but Pi does not implement that env var (verified against pi 0.84.2 and the latest 0.84.4) — it is a no-op.change_titletool nor injects a title instruction, so nothing ever sets the session title. Sessions fall back to path/id names forever.Related: #1669 (hub-side auto title-provider approach), #1672 (change_title availability for other launcher types).
Approach
Give Pi sessions the same treatment the other launchers already get — a tool plus an instruction — using Pi's own extension mechanism:
titleExtension.tsembeds a small Pi extension source (plain JS as a string) and materializes it under<happyHomeDir>/runtime/<version>/pi/hapi-title-extension.tsat launch, then passes it to Pi via--extension(available since pi 0.35.0).hapi_change_titletool (naming mirrors the OpenCode launcher'shapi_change_title, so user extensions registering their own tools cannot collide) and injects a first-turn system-prompt instruction with the same wording used for Claude/Codex.ctx.ui.setTitle(), which flows through the existing Pi extension UI bridge (extensionUiHandler→syncTitle) into session metadata. No extra title-provider round-trip and no changes to the title-suggestion service.Also removes the dead
PI_RPC_EMIT_TITLEenv var.Behavior notes
--extensionlanded, 2026-01).Testing
titleExtension.test.ts(source sanity + materialization idempotency); updatedrunPi.test.tsstartup assertions (transport args now include--extension <path>).bun run typecheckpasses;vitest run src/pi/titleExtension.test.ts src/pi/runPi.test.ts→ 61 passed.setTitleextension UI request reached the hub, which persisted it as the session title.AI-generated code disclosure
Per the Vibe Coding policy: code authored with AI assistance (GLM-5.3-flash via the pi coding agent), reviewed and tested as described above.