From 56e5c3567863d8a8b9eb70cc068838a8fb49653c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 20:11:10 -0500 Subject: [PATCH 1/4] wip(fleet): F3-m2 native Pi heartbeat + sidecar reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — not for merge yet. Implements the core of the custom Pi harness (R14/R15): - runtime/pi/mosaic-extension.ts: native heartbeat — writes the same .hb contract (ts/pid/status[/model]) on a MOSAIC_HEARTBEAT_INTERVAL timer; turn_start/turn_end flip status busy/ok; model self-report via ctx.model; touches a .hb.native precedence marker. Also FIXES a latent bug: session_end -> session_shutdown (the old handler never fired) + corrects the import scope to @earendil-works/pi-coding-agent. - start-agent-session.sh: sidecar DEFERS when the .hb.native marker is fresh (< 2x interval), else writes the fallback — native precedence, sidecar fallback, same contract so fleet ps is agnostic (per Lead's design). Generated script validated (bash -n) + deferral/fallback behavior tested. REMAINING before PR: surface model in `fleet ps` (parseHeartbeat + row); vitest for the native-HB writer; "proper tool usage" (registerTool) piece; rebase onto #599's %q sidecar (overlap on the printf line). Refs #588 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../framework/runtime/pi/mosaic-extension.ts | 98 ++++++++++++++++++- .../tools/fleet/start-agent-session.sh | 2 +- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index 92f4185..d4749be 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -9,8 +9,15 @@ * 4. Memory routing — remind agent to use ~/.config/mosaic/memory/ */ -import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'; -import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { + existsSync, + readFileSync, + writeFileSync, + unlinkSync, + mkdirSync, + renameSync, +} from 'node:fs'; import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import { execSync, spawnSync } from 'node:child_process'; @@ -25,6 +32,57 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo // Helpers // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Native heartbeat (fleet R14/R15) +// --------------------------------------------------------------------------- +// When this agent runs under the Mosaic fleet (MOSAIC_AGENT_NAME set), the +// extension writes its OWN heartbeat in the same .hb contract `fleet ps` reads +// (ts/pid/status[/model]) and touches a `.hb.native` precedence marker so the +// shell sidecar defers. Native HB knows the real turn state (busy/ok), so it is +// more accurate than the pane-PID-only sidecar fallback. +const HB_AGENT_NAME = process.env['MOSAIC_AGENT_NAME'] ?? ''; +const HB_RUN_DIR = process.env['MOSAIC_HEARTBEAT_RUN_DIR'] ?? join(MOSAIC_HOME, 'fleet', 'run'); +const HB_INTERVAL_MS = (() => { + const s = Number.parseInt(process.env['MOSAIC_HEARTBEAT_INTERVAL'] ?? '', 10); + return Number.isFinite(s) && s > 0 ? s * 1000 : 15_000; +})(); + +function nativeHbEnabled(): boolean { + return HB_AGENT_NAME.length > 0; +} + +function readModelId(ctx: ExtensionContext): string | null { + const m = ctx.model as unknown as { id?: string; name?: string } | undefined; + return m?.id ?? m?.name ?? null; +} + +function writeNativeHeartbeat(status: 'ok' | 'busy', model: string | null): void { + if (!nativeHbEnabled()) return; + try { + mkdirSync(HB_RUN_DIR, { recursive: true }); + const hb = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb`); + const lines = [`ts=${nowIso()}`, `pid=${process.pid}`, `status=${status}`]; + if (model) lines.push(`model=${model}`); + const tmp = `${hb}.tmp.${process.pid}`; + writeFileSync(tmp, lines.join('\n') + '\n'); + renameSync(tmp, hb); // atomic replace — fleet ps never reads a partial file + // Precedence marker: tells the shell sidecar that native HB is authoritative. + writeFileSync(join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`), nowIso() + '\n'); + } catch { + // Best-effort: never let heartbeat I/O disrupt the Pi session. + } +} + +function clearNativeMarker(): void { + if (!nativeHbEnabled()) return; + try { + const m = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`); + if (existsSync(m)) unlinkSync(m); // native stopping — let the sidecar take over + } catch { + /* ignore */ + } +} + function safeRead(filePath: string): string | null { try { return readFileSync(filePath, 'utf-8'); @@ -187,6 +245,9 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string { export default function register(pi: ExtensionAPI) { let sessionCwd = process.cwd(); + let hbStatus: 'ok' | 'busy' = 'ok'; + let hbModel: string | null = null; + let hbTimer: ReturnType | null = null; // ── Session Start ───────────────────────────────────────────────────── pi.on('session_start', async (_event, ctx) => { @@ -207,10 +268,39 @@ export default function register(pi: ExtensionAPI) { } else { ctx.ui.notify('Mosaic framework loaded', 'info'); } + + // Native heartbeat: write immediately, then on an interval. Idle = 'ok'; + // turn_start/turn_end flip the status so `fleet ps` reflects real activity. + if (nativeHbEnabled()) { + hbModel = readModelId(ctx); + writeNativeHeartbeat('ok', hbModel); + hbTimer = setInterval(() => writeNativeHeartbeat(hbStatus, hbModel), HB_INTERVAL_MS); + if (typeof hbTimer.unref === 'function') hbTimer.unref(); + } }); - // ── Session End ─────────────────────────────────────────────────────── - pi.on('session_end', async (_event, _ctx) => { + // ── Turn lifecycle → accurate busy/ok heartbeat ─────────────────────── + pi.on('turn_start', async (_event, ctx) => { + hbStatus = 'busy'; + hbModel = readModelId(ctx) ?? hbModel; + writeNativeHeartbeat('busy', hbModel); + }); + pi.on('turn_end', async (_event, ctx) => { + hbStatus = 'ok'; + hbModel = readModelId(ctx) ?? hbModel; + writeNativeHeartbeat('ok', hbModel); + }); + + // ── Session Shutdown ────────────────────────────────────────────────── + // (The pi API event is 'session_shutdown'; the prior 'session_end' handler + // never fired — fixed here so repo hooks + lock cleanup actually run.) + pi.on('session_shutdown', async (_event, _ctx) => { + if (hbTimer) { + clearInterval(hbTimer); + hbTimer = null; + } + clearNativeMarker(); + // Run repo session-end hook runRepoHook(sessionCwd, 'session-end'); diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 356b630..1d70409 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -129,7 +129,7 @@ _start_heartbeat_sidecar() { # references to any variables from this script's environment. local sidecar_script sidecar_script=$(printf \ - 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ + 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ "$hb_file" "$pane_pid" "$interval") # setsid + disown ensures the sidecar survives this script exiting. -- 2.49.1 From bda38bddc135b71f1f8683da68fa512388dc5f0e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 20:17:42 -0500 Subject: [PATCH 2/4] feat(fleet): surface self-reported model in fleet ps parseHeartbeat now reads an optional model= line from the heartbeat file (written by native runtime heartbeats) into HeartbeatInfo.model, and fleet ps surfaces it as a MODEL column (table) and in --json (via rows[].heartbeat.model). Legacy/sidecar heartbeats omit the line and report model=null, so the column shows '-'. Closes the model self-report gap end-to-end with the native Pi heartbeat writer (F3-m2): the runtime self-reports its active model and the fleet operator can see it in ps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83 --- packages/mosaic/src/commands/fleet.spec.ts | 11 +++++++++++ packages/mosaic/src/commands/fleet.ts | 13 +++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 401469d..f0c3262 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -833,6 +833,17 @@ describe('fleet ps — heartbeat parsing', () => { expect(hb.pid).toBe(12345); expect(hb.status).toBe('ok'); expect(hb.ageMs).toBe(10_000); + // No model= line in a legacy/sidecar heartbeat → model is null. + expect(hb.model).toBeNull(); + }); + + it('parses a self-reported model id from a native heartbeat (model= line)', () => { + const ts = new Date(NOW - 5_000).toISOString(); + const content = `ts=${ts}\npid=42\nstatus=busy\nmodel=openai-codex/gpt-5.5:high\n`; + const hb = parseHeartbeat(content, NOW); + expect(hb.model).toBe('openai-codex/gpt-5.5:high'); + expect(hb.status).toBe('busy'); + expect(hb.health).toBe('healthy'); }); it('reports stale when heartbeat is older than 3×interval', () => { diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 8b1aee1..eb0c462 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -390,6 +390,8 @@ export interface HeartbeatInfo { /** healthy | stale | unknown */ health: 'healthy' | 'stale' | 'unknown'; ageMs: number | null; + /** Model id the runtime self-reported in its heartbeat (native HB only), else null. */ + model: string | null; } export interface AgentPsRow { @@ -490,15 +492,17 @@ export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome( * ts= * pid= * status= + * model= (optional — native runtime heartbeats self-report it) */ export function parseHeartbeat(content: string | null, nowMs = Date.now()): HeartbeatInfo { if (content === null) { - return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null }; + return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null, model: null }; } const lines = content.split('\n'); let ts: Date | null = null; let pid: number | null = null; let status: 'ok' | 'busy' | null = null; + let model: string | null = null; for (const line of lines) { const [key, ...rest] = line.split('='); const val = rest.join('=').trim(); @@ -510,6 +514,8 @@ export function parseHeartbeat(content: string | null, nowMs = Date.now()): Hear if (Number.isFinite(n)) pid = n; } else if (key === 'status' && (val === 'ok' || val === 'busy')) { status = val; + } else if (key === 'model' && val) { + model = val; } } const thresholdMs = heartbeatIntervalMs() * HEARTBEAT_HEALTHY_MULTIPLIER; @@ -519,7 +525,7 @@ export function parseHeartbeat(content: string | null, nowMs = Date.now()): Hear ageMs = nowMs - ts.getTime(); health = ageMs <= thresholdMs ? 'healthy' : 'stale'; } - return { ts, pid, status, health, ageMs }; + return { ts, pid, status, health, ageMs, model }; } /** @@ -1123,6 +1129,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = 'PID'.padEnd(8), 'IDLE'.padEnd(8), 'HB'.padEnd(12), + 'MODEL'.padEnd(22), 'FLAGS', ].join(' '); console.log(header); @@ -1137,6 +1144,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = row.heartbeat.ageMs !== null ? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}` : `unknown`; + const model = row.heartbeat.model ?? '-'; const flags: string[] = []; if (!row.managed) flags.push('UNMANAGED'); if (row.driftFlag) flags.push('DRIFT'); @@ -1153,6 +1161,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = pid.padEnd(8), idle.padEnd(8), hbAge.padEnd(12), + model.padEnd(22), flags.join(','), ].join(' '), ); -- 2.49.1 From b26bbb02e9bc9cce171939ebc00c214f6b00c98b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 20:20:36 -0500 Subject: [PATCH 3/4] feat(pi): register model-callable mosaic_mission_status tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a first-class registerTool (R14 'proper tool usage') so the Pi agent can load its active Mosaic mission, milestone progress, task counts, and latest scratchpad as a tool call before planning — instead of shelling out or guessing. Reuses detectMission/buildMissionSummary; returns AgentToolResult text + structured details. promptGuidelines names the tool explicitly per the pi extension authoring contract. Tool shape verified against @earendil-works/pi-coding-agent@0.79.9 ToolDefinition (name/label/description/promptSnippet/promptGuidelines/ parameters + execute(toolCallId,params,signal,onUpdate,ctx)). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83 --- .../framework/runtime/pi/mosaic-extension.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index d4749be..cbf783f 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -10,6 +10,7 @@ */ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; import { existsSync, readFileSync, @@ -342,4 +343,32 @@ export default function register(pi: ExtensionAPI) { } }, }); + + // ── Register mosaic_mission_status tool (model-callable) ────────────── + // R14 "proper tool usage": give the agent a first-class tool to load its + // active Mosaic mission, milestone progress, task counts, and latest + // scratchpad — so it self-orients on in-flight work before planning, + // instead of shelling out or guessing. Mirrors the /mosaic-status command + // but returns the summary as tool output the LLM can read. + pi.registerTool({ + name: 'mosaic_mission_status', + label: 'Mosaic Mission Status', + description: + 'Return the active Mosaic mission, milestone progress, task counts, and latest scratchpad for the current project. Returns a note when no mission is active.', + promptSnippet: 'Read the active Mosaic mission + task state for the current project', + promptGuidelines: [ + 'Use mosaic_mission_status at the start of a session or task to load the active mission, milestone progress, and open tasks before planning work.', + ], + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + const mission = detectMission(sessionCwd); + const text = mission + ? buildMissionSummary(sessionCwd, mission) + : 'No active Mosaic mission in this project.'; + return { + content: [{ type: 'text', text }], + details: mission ? { ...mission } : { active: false }, + }; + }, + }); } -- 2.49.1 From 5c643cd54ef532beb4c10a16908ed6e8fcc33d15 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 20:41:42 -0500 Subject: [PATCH 4/4] fix(fleet): bake MOSAIC_AGENT_NAME into the agent pane so native HB fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-validation (Lead, w-jarvis) found the native heartbeat was INERT in production: the Pi extension gates on MOSAIC_AGENT_NAME, but tmux panes inherit the tmux SERVER environment (not this script's env, nor the systemd unit's), so the name was empty in-pane for BOTH ad-hoc and systemd agents. Result: no native .hb, no model self-report — only the sidecar fallback ran. Fix: %q-quote the agent name and export it into the pane command alongside PATH, so the extension sees it -> nativeHbEnabled() -> writes .hb with model + busy/ok turn state. Re-validated live via the launcher (isolated socket, real pi on glm-5.2): - pane env now carries MOSAIC_AGENT_NAME - .hb written with status=ok + model=glm-5.2 + .hb.native marker - status flips ok -> busy on a real turn -> ok on turn end - sidecar defers to the fresh native marker Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83 --- .../framework/tools/fleet/start-agent-session.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 1d70409..7aacb67 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -90,11 +90,18 @@ MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) # # We build the snippet as a double-quoted here-string embedded in a printf call # to avoid nested quoting problems. +# +# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux +# server environment (not this script's, and not the systemd unit's), so the +# name would otherwise be empty in-pane and the runtime's native heartbeat +# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a +# safe single bash token regardless of the name's characters. +AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then - PANE_SHELL_SNIPPET="export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" + PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" else - PANE_SHELL_SNIPPET="exec ${MOSAIC_AGENT_COMMAND}" + PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; exec ${MOSAIC_AGENT_COMMAND}" fi mkdir -p "$MOSAIC_AGENT_WORKDIR" -- 2.49.1