Skip to content

feat(pi): auto-title Pi sessions via bundled hapi_change_title extension - #1719

Merged
tiann merged 4 commits into
tiann:mainfrom
HongChenGG:feat/pi-auto-title
Sep 9, 2026
Merged

feat(pi): auto-title Pi sessions via bundled hapi_change_title extension#1719
tiann merged 4 commits into
tiann:mainfrom
HongChenGG:feat/pi-auto-title

Conversation

@HongChenGG

Copy link
Copy Markdown
Contributor

Problem

Pi sessions in HAPI never get automatic titles:

  • HAPI launches Pi with 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.
  • Unlike the Claude/Codex/OpenCode launchers, the Pi bridge neither registers a change_title tool 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:

  1. titleExtension.ts embeds a small Pi extension source (plain JS as a string) and materializes it under <happyHomeDir>/runtime/<version>/pi/hapi-title-extension.ts at launch, then passes it to Pi via --extension (available since pi 0.35.0).
  2. The extension registers a namespaced hapi_change_title tool (naming mirrors the OpenCode launcher's hapi_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.
  3. The model calls the tool; the extension applies the title via ctx.ui.setTitle(), which flows through the existing Pi extension UI bridge (extensionUiHandlersyncTitle) into session metadata. No extra title-provider round-trip and no changes to the title-suggestion service.

Also removes the dead PI_RPC_EMIT_TITLE env var.

Behavior notes

Testing

  • Unit: new titleExtension.test.ts (source sanity + materialization idempotency); updated runPi.test.ts startup assertions (transport args now include --extension <path>).
  • bun run typecheck passes; vitest run src/pi/titleExtension.test.ts src/pi/runPi.test.ts → 61 passed.
  • Manual end-to-end with a real Pi RPC session: the model called the tool on its first turn and the setTitle extension 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.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_start stops injecting the instruction once titled becomes 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

Comment thread cli/src/pi/titleExtension.ts Outdated
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread cli/src/pi/titleExtension.ts Outdated
// launcher convention) so user extensions registering their own tools
// cannot collide with it.
pi.on('before_agent_start', function (event) {
if (titled) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
@HongChenGG

Copy link
Copy Markdown
Contributor Author

Both findings are addressed in ec6d279:

[Major] atomic publicationmaterializePiTitleExtension now writes to a unique temp file (<file>.<pid>.<uuid>.tmp) and moves it into place with rename, so a concurrent launcher can never observe a half-written extension. If rename loses a race against a launcher that already published the file, the existing copy is kept and the temp file is cleaned up.

[Minor] persistent instruction — the titled short-circuit is removed; before_agent_start now injects the instruction on every turn, preserving the objective-change retitle rule from the persistent Claude/Codex instruction.

Tests added per the review's request:

  • concurrent materialization: 8 parallel launches must all resolve to the same path with complete content and leave no .tmp files behind;
  • handler test that loads the materialized source as a module, executes the title tool, and asserts a later turn still carries the instruction (Rename only when...).

Full suite: 63 passed, bun run typecheck clean.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Keep the generated extension compatible with Pi versions HAPI still accepts — the source uses the new typebox module name, new tool execute argument order, and a hasUI guard 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. Evidence cli/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

Comment thread cli/src/pi/titleExtension.ts Outdated
* child process.
*/
export const PI_TITLE_EXTENSION_SOURCE = `// Generated by HAPI - auto session titling for Pi sessions.
import { Type } from 'typebox';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
@HongChenGG

Copy link
Copy Markdown
Contributor Author

Addressed in eb5d0f0 — the extension source is now backward-compatible with the Pi releases HAPI accepts:

  • TypeBox specifier: imports Type from '@sinclair/typebox', which the legacy loaders expose and the current loader also resolves (it aliases both 'typebox' and '@sinclair/typebox' to its bundled module — verified in pi 0.84.2 dist/core/extensions/loader.js).
  • execute() argument order: the context is normalized positionally (fifth if it carries the ui bridge, else fourth), covering both current (id, params, signal, onUpdate, ctx) and legacy (id, params, onUpdate, ctx, signal).
  • hasUI guard removed: ctx.ui.setTitle() is now called directly (guarded only against a missing ctx.ui), since this extension only runs under HAPI's RPC bridge and older RPC releases report hasUI: false even though setTitle emits the event.

Tests added per the review request: both call orders are exercised, including hasUI: false, asserting setTitle fires in each case. Also verified on a live pi 0.84.2 RPC session that the extension loads cleanly with the '@sinclair/typebox' specifier. Suite: 64 passed, bun run typecheck clean.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@heavygee heavygee added area:cli CLI, runner, agent wrappers community-pr PR from non-collaborator contributor enhancement New feature or request labels Sep 4, 2026
@tiann
tiann merged commit 905d89e into tiann:main Sep 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli CLI, runner, agent wrappers community-pr PR from non-collaborator contributor enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants