Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@ Read these before the relevant activity:
- **`docs/praxis/worktree-agents.md`** — before spawning parallel agent builds with `isolation: "worktree"`
- **`docs/praxis/manual-testing.md`** — before outer-loop UI testing or fixture capture
- **`docs/praxis/dev-server-logs.md`** — before reading runtime logs from the dev server or browser
- **`docs/praxis/pi-types.md`** — before typing Brunch seams over Pi session, extension, or UI APIs
2 changes: 2 additions & 0 deletions bin/brunch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/brunch.js"
51 changes: 51 additions & 0 deletions docs/praxis/pi-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Pi type ownership notes

Brunch builds on the installed `@earendil-works/pi-coding-agent` package. When typing Brunch seams over Pi, treat Pi's exported declarations as the source of truth for Pi-owned envelopes, and Brunch's local domain modules as the source of truth for Brunch payloads.

## Import or project from Pi when possible

Use Pi's public package exports for session and extension shapes:

- `SessionHeader` owns JSONL session-header structure.
- `CustomEntry<T>` owns the extension custom-entry envelope; Brunch owns the `T` payload for `brunch.*` entries.
- `ExtensionFactory`, handler overloads, and extension context types own extension-event and context shapes.
- `ExtensionUIContext` owns UI methods such as `setWidget` and `setTitle`; tests should use `Pick<ExtensionUIContext, ...>` rather than restating method signatures.

Good pattern:

```ts
type SessionBindingEntry = CustomEntry<SessionBindingData> & {
customType: typeof SESSION_BINDING_TYPE
data: SessionBindingData
}
```

Pi owns the entry envelope; Brunch owns `SessionBindingData`.

## Let handler overloads infer event types

Some useful extension event types may exist in Pi's internal `.d.ts` files but are not exported from the package root in the installed version. Prefer relying on `ExtensionFactory` / `pi.on(...)` overload inference instead of importing deep internal paths.

Good pattern:

```ts
const extension: ExtensionFactory = (pi) => {
pi.on("message_start", async (event, ctx) => {
if (event.message.role === "assistant") {
// event and ctx are Pi-typed by the overload
}
})
}
```

Avoid importing from non-exported deep paths such as `dist/core/extensions/types.js`; those are not part of the package `exports` contract and may fail under NodeNext package resolution.

## Installed package is executable truth

For debugging, source checkouts such as `~/Clones/earendil-works/pi` are useful for readability, but the installed `node_modules/@earendil-works/pi-coding-agent` version is what Brunch compiles and runs against. If source and installed declarations disagree, code to the installed package until the dependency is updated.

## Keep private seams visibly local

When Brunch must cross a Pi private seam, keep the local escape-hatch type tiny and colocated with the cast. Do not promote private Pi details into broad local interfaces.

Example: the current pre-assistant JSONL flush compatibility path needs `_rewriteFile()`, which Pi marks private and does not expose as a public type. A minimal local type at the call site is preferable to pretending this is a stable Pi contract.
102 changes: 51 additions & 51 deletions memory/PLAN.md

Large diffs are not rendered by default.

179 changes: 143 additions & 36 deletions memory/SPEC.md

Large diffs are not rendered by default.

151 changes: 151 additions & 0 deletions src/brunch-tui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { mkdtemp, readFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import { describe, expect, it } from "vitest"

import {
SessionManager,
type ExtensionContext,
type ExtensionUIContext,
} from "@earendil-works/pi-coding-agent"

import {
createBrunchChromeExtension,
formatChromeWidgetLines,
runBrunchTui,
} from "./brunch-tui.js"
import { verifyWorkspaceSessionStores } from "./workspace-session-coordinator.js"

describe("Brunch TUI boot", () => {
it("gates spec selection through the coordinator before launching interactive mode", async () => {
const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-"))
const events: string[] = []

await runBrunchTui({
cwd,
selectSpecTitle: async () => {
events.push("select-spec")
return "Gated spec"
},
launchInteractive: async ({ workspace }) => {
events.push(`launch:${workspace.spec.title}`)
},
})

expect(events).toEqual(["select-spec", "launch:Gated spec"])
const oracle = await verifyWorkspaceSessionStores({
cwd,
expectedSessionCount: 1,
})
expect(oracle.ok).toBe(true)
if (!oracle.ok) {
expect(oracle.errors).toEqual([])
}
})

it("passes coordinator chrome state to the persistent chrome widget", async () => {
const lines = formatChromeWidgetLines({
cwd: "/tmp/project",
spec: { id: "spec-1", title: "Spec One" },
phase: "elicitation",
chatMode: "responding-to-elicitation",
})

expect(lines.join("\n")).toContain("cwd: /tmp/project")
expect(lines.join("\n")).toContain("spec: Spec One")
expect(lines.join("\n")).toContain("phase: elicitation")
expect(lines.join("\n")).toContain("chat: responding-to-elicitation")
})

it("binds replacement sessions through internal session boundary events", async () => {
const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-"))
const manager = SessionManager.create(cwd, join(cwd, ".brunch", "sessions"))
const boundSessionIds: string[] = []
const widgets = new Map<string, string[]>()
const ui: FakeExtensionUi = {
setWidget: (key: string, content: unknown) => {
if (isStringArray(content)) {
widgets.set(key, content)
}
},
setTitle: (_title: string) => {},
}
const ctx: FakeExtensionContext = { sessionManager: manager, ui }
let sessionStart: ((
event: unknown,
ctx: FakeExtensionContext,
) => Promise<void>) | undefined
let beforeAgentStart: ((
event: unknown,
ctx: FakeExtensionContext,
) => Promise<void>) | undefined
let messageStart: ((
event: unknown,
ctx: FakeExtensionContext,
) => Promise<void>) | undefined

createBrunchChromeExtension(
{
cwd,
spec: { id: "spec-1", title: "Spec One" },
phase: "elicitation",
chatMode: "responding-to-elicitation",
},
(sessionManager) => {
boundSessionIds.push(sessionManager.getSessionId())
},
)({
on: (event: string, handler: typeof sessionStart) => {
if (event === "session_start") {
sessionStart = handler
}
if (event === "before_agent_start") {
beforeAgentStart = handler
}
if (event === "message_start") {
messageStart = handler
}
},
} as never)

await sessionStart?.({}, ctx)
await beforeAgentStart?.({}, ctx)
await messageStart?.(
{ type: "message_start", message: { role: "user" } },
ctx,
)
await messageStart?.(
{ type: "message_start", message: { role: "assistant" } },
ctx,
)

expect(boundSessionIds).toEqual([
manager.getSessionId(),
manager.getSessionId(),
manager.getSessionId(),
])
expect(widgets.get("brunch.chrome")?.join("\n")).toContain("Spec One")
})

it("keeps session creation and binding out of the TUI boot adapter", async () => {
const source = await readFile(
new URL("./brunch-tui.ts", import.meta.url),
"utf8",
)

expect(source).not.toContain("SessionManager.create")
expect(source).not.toContain("appendCustomEntry")
expect(source).not.toContain("brunch.session_binding")
})
})

type FakeExtensionContext = Pick<ExtensionContext, "sessionManager"> & {
ui: FakeExtensionUi
}

type FakeExtensionUi = Pick<ExtensionUIContext, "setWidget" | "setTitle">

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string")
}
146 changes: 146 additions & 0 deletions src/brunch-tui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createInterface } from "node:readline/promises"
import process from "node:process"

import {
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
InteractiveMode,
SessionManager,
type CreateAgentSessionRuntimeFactory,
type ExtensionFactory,
} from "@earendil-works/pi-coding-agent"

import {
createWorkspaceSessionCoordinator,
type WorkspaceSessionChromeState,
type WorkspaceSessionCoordinator,
type WorkspaceSessionReadyState,
} from "./workspace-session-coordinator.js"

export interface BrunchTuiLaunchContext {
workspace: WorkspaceSessionReadyState
coordinator: WorkspaceSessionCoordinator
}

export interface BrunchTuiOptions {
cwd?: string
coordinator?: WorkspaceSessionCoordinator
selectSpecTitle?: () => Promise<string | undefined>
launchInteractive?: (context: BrunchTuiLaunchContext) => Promise<void>
}

export async function runBrunchTui(
options: BrunchTuiOptions = {},
): Promise<void> {
const cwd = options.cwd ?? process.cwd()
const coordinator =
options.coordinator ?? createWorkspaceSessionCoordinator({ cwd })

let workspaceState = await coordinator.openExisting()
if (workspaceState.status === "select_spec") {
const title = await (options.selectSpecTitle ?? promptForSpecTitle)()
if (!title) {
return
}
workspaceState = await coordinator.startOrCreate({ specTitle: title })
}

if (workspaceState.status === "needs_human") {
throw new Error(workspaceState.reason)
}

await (options.launchInteractive ?? launchPiInteractive)({
workspace: workspaceState,
coordinator,
})
}

export function formatChromeWidgetLines(
chrome: WorkspaceSessionChromeState,
): string[] {
const spec = chrome.spec ? chrome.spec.title : "<none>"
return [
`brunch cwd: ${chrome.cwd}`,
` spec: ${spec} phase: ${chrome.phase} chat: ${chrome.chatMode}`,
]
}

export function createBrunchChromeExtension(
chrome: WorkspaceSessionChromeState,
onSessionBoundary?: (sessionManager: SessionManager) => Promise<void> | void,
): ExtensionFactory {
return (pi) => {
pi.on("session_start", async (_event, ctx) => {
await onSessionBoundary?.(ctx.sessionManager as SessionManager)
ctx.ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), {
placement: "aboveEditor",
})
ctx.ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`)
})
pi.on("before_agent_start", async (_event, ctx) => {
await onSessionBoundary?.(ctx.sessionManager as SessionManager)
})
pi.on("message_start", async (event, ctx) => {
if (event.message.role === "assistant") {
await onSessionBoundary?.(ctx.sessionManager as SessionManager)
}
})
}
}

async function promptForSpecTitle(): Promise<string | undefined> {
const rl = createInterface({ input: process.stdin, output: process.stdout })
try {
const answer = await rl.question("Create/select Brunch spec title: ")
const title = answer.trim()
return title.length > 0 ? title : undefined
} finally {
rl.close()
}
}

async function launchPiInteractive({
workspace,
coordinator,
}: BrunchTuiLaunchContext): Promise<void> {
const agentDir = getAgentDir()
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir: runtimeAgentDir,
sessionManager,
}) => {
const services = await createAgentSessionServices({
cwd,
agentDir: runtimeAgentDir,
resourceLoaderOptions: {
extensionFactories: [
createBrunchChromeExtension(
workspace.chrome,
async (sessionManager) => {
await coordinator.bindCurrentSpecToSession(sessionManager)
},
),
],
},
})
const created = await createAgentSessionFromServices({
services,
sessionManager,
})
return {
...created,
services,
diagnostics: services.diagnostics,
}
}

const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: workspace.cwd,
agentDir,
sessionManager: workspace.session.manager,
})

await new InteractiveMode(runtime).run()
}
Loading
Loading