Skip to content
Merged
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
36 changes: 36 additions & 0 deletions docs/readme-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,42 @@ flowchart TD

This is guidance through existing tools, not a new CLI, phase, state engine, or execution harness. Static prompt tests and scripted hook checks prove instruction delivery, not autonomous model adherence; actual create/update/resume behavior requires observed Pi sessions.

## Herdr active-work summary

Under Herdr, root interactive Pi sessions publish only the `in_progress` Todo
title, prefixed with `◐`. No phase, working/verifying label or tool fallback is
used; prompts, tool arguments, output and Todo notes never supply text. Missing,
invalid or empty active titles clear the display. Children, RPC/print modes and
missing Herdr/socket contexts do not publish. The packaged extension loads through
`gentle-shell`, including its isolated home; loading does not change your config.

Herdr 0.8.2/protocol 20 needs **two separate rows**, not a newline inside a token:
add `['$summary'], ['$summary2']` to the Agents sidebar rows in your config.
The first row is `◐ title`; the extension prefixes the optional continuation with
two spaces, though Herdr may normalize leading whitespace.
Text wraps by display cells, preferring word boundaries unless a grapheme-safe
word split avoids unnecessary truncation across the two rows. Ellipsis appears
only on the last overflowing row, or on an extremely narrow first row. Each token
is capped at 80 Unicode scalar characters, including icon, spaces and ellipsis,
before Herdr normalizes it; both tokens together are capped at 256 UTF-8 bytes
(excluding private newline framing). ANSI/bidi controls are removed while emoji
ZWJ sequences are preserved. Pathological oversized graphemes may be replaced by
an ellipsis.

Width comes from root `sidebar_width` in `session.json` beside `HERDR_SOCKET_PATH`,
less five columns for divider, possible scrollbar and the detail-row prefix.
Snapshots are bounded to 256 KiB and cached for five seconds while active; persisted
width can itself lag resize by five seconds. This is not live geometry. Missing,
malformed, oversized or unsupported snapshots fall back to 24 available columns;
other layouts may need manual adjustment. No CLI/socket query measures width.

Source `gentle:activity` owns only `summary`/`summary2`, not lifecycle. Updates are
latest-only, serialized and best-effort offline, with a 30-second TTL refreshed every
10 seconds while active. Each report clears an unused second row; idle, session
changes and shutdown clear both. TTL expiry covers abrupt exits. The managed Herdr
bridge remains lifecycle authority. Reports invoke `HERDR_BIN_PATH` when supplied,
otherwise `herdr` from `PATH`, without a shell.

## Navigation

- [Capabilities](#capability-reference)
Expand Down
81 changes: 81 additions & 0 deletions extensions/gentle-herdr-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { statSync } from "node:fs";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { activitySummary, ActivityPublisher, createActivityColumns, metadataTransport } from "../lib/herdr-activity.ts";
import { replayTodo, type TodoState } from "../lib/shell-todo.ts";

interface ActivityOptions {
env?: NodeJS.ProcessEnv;
isSocket?: (path: string) => boolean;
send?: (summary: string | null, seq: number) => Promise<void>;
watch?: (tick: () => void) => () => void;
now?: () => number;
columns?: () => number;
}

export function createHerdrActivityExtension(options: ActivityOptions = {}) {
return (pi: ExtensionAPI): void => {
const env = options.env ?? process.env;
if (env.GENTLE_PI_AGENTS_CHILD === "1" || env.HERDR_ENV !== "1" ||
!env.HERDR_PANE_ID?.trim() || !env.HERDR_SOCKET_PATH?.trim()) return;
const isSocket = options.isSocket ?? ((path: string) => {
try { return statSync(path).isSocket(); } catch { return false; }
});
let publisher: ActivityPublisher | undefined;
let stop: (() => void) | undefined;
let owner: string | undefined;
let tasks: TodoState["tasks"] = [];
let active = false;
let refreshAt = 0;
const columns = options.columns ?? createActivityColumns(env.HERDR_SOCKET_PATH!, options.now);
const owns = (ctx: ExtensionContext) => owner !== undefined && ctx.sessionManager.getSessionId() === owner;
const publish = () => {
if (!active || !publisher) return;
const now = (options.now ?? Date.now)();
publisher.update(activitySummary(tasks, columns()), now >= refreshAt);
if (now >= refreshAt) refreshAt = now + 10000;
};
const idle = () => { active = false; publisher?.update(null); };
const startSession = (ctx: ExtensionContext) => {
idle(); stop?.(); stop = undefined;
owner = undefined;
if (ctx.mode !== "tui" || !ctx.hasUI || !isSocket(env.HERDR_SOCKET_PATH!)) return;
owner = ctx.sessionManager.getSessionId();
if (!owner) return;
publisher ??= new ActivityPublisher(options.send ?? metadataTransport(env));
publisher.update(null, true);
tasks = replayTodo(ctx.sessionManager.getBranch()).tasks;
const watch = options.watch ?? ((tick: () => void) => {
const timer = setInterval(tick, 1000); timer.unref(); return () => clearInterval(timer);
});
// Refresh bounded cached geometry and TTL during long tools/model calls.
stop = watch(publish);
};
pi.on("session_start", (_event, ctx) => startSession(ctx));
pi.on("session_tree", (_event, ctx) => {
if (!owns(ctx)) return;
idle(); tasks = replayTodo(ctx.sessionManager.getBranch()).tasks;
});
pi.on("agent_start", (_event, ctx) => {
if (!owns(ctx)) return;
active = true; refreshAt = 0; publish();
});
pi.on("tool_execution_end", (event, ctx) => {
if (!owns(ctx)) return;
if (event.toolName !== "todo" || event.isError) return;
const result = event.result as { details?: { gentleTodo?: TodoState; error?: unknown } };
const state = result?.details?.gentleTodo;
if (result?.details && Object.hasOwn(result.details, "gentleTodo") && !result.details.error) {
tasks = Array.isArray(state?.tasks) ? state.tasks : [];
publish();
}
});
pi.on("agent_settled", (_event, ctx) => { if (owns(ctx)) idle(); });
pi.on("session_shutdown", (_event, ctx) => {
if (!owns(ctx)) return;
idle(); stop?.(); stop = undefined;
publisher?.close(); publisher = undefined; owner = undefined; tasks = [];
});
};
}

export default createHerdrActivityExtension();
183 changes: 183 additions & 0 deletions lib/herdr-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { execFile } from "node:child_process";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";

const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });

const FALLBACK_COLUMNS = 24;
const MAX_SNAPSHOT_BYTES = 256 * 1024;

/** Read only a bounded local snapshot, never poll Herdr's CLI/socket for geometry. */
export function createActivityColumns(socket: string, now: () => number = Date.now): () => number {
const path = isAbsolute(socket) ? join(dirname(socket), "session.json") : undefined;
let refreshAt = -Infinity;
let columns = FALLBACK_COLUMNS;
return () => {
const time = now();
if (time < refreshAt) return columns;
refreshAt = time + 5000;
columns = FALLBACK_COLUMNS;
if (!path) return columns;
let fd: number | undefined;
try {
// Reject FIFOs after opening without waiting for a writer; unsupported flags are omitted.
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
const stat = fstatSync(fd);
if (!stat.isFile() || stat.size > MAX_SNAPSHOT_BYTES) return columns;
// One extra byte rejects files that grew after fstat, without unlimited reads.
const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
const size = readSync(fd, buffer, 0, buffer.length, 0);
if (size > MAX_SNAPSHOT_BYTES) return columns;
const snapshot = JSON.parse(buffer.toString("utf8", 0, size));
const width: unknown = snapshot?.sidebar_width;
if (typeof width === "number" && Number.isInteger(width) && width >= 6 && width <= 512) {
// Divider + possible scrollbar + three-space agent detail prefix.
columns = width - 5;
}
} catch { /* Missing/malformed snapshots use conservative geometry. */ }
finally {
try { if (fd !== undefined) closeSync(fd); } catch { /* Best effort close. */ }
}
return columns;
};
}

/** Fit whole graphemes within cell, UTF-8 byte and Unicode scalar budgets. */
function fit(parts: string[], columns: number, bytes: number, characters: number, preferWords = true): number {
let cells = 0;
let used = 0;
let scalars = 0;
let count = 0;
let space = -1;
for (const part of parts) {
const width = visibleWidth(part);
const size = Buffer.byteLength(part);
const length = Array.from(part).length;
if (cells + width > columns || used + size > bytes || scalars + length > characters) break;
if (part === " ") space = count;
cells += width;
used += size;
scalars += length;
count++;
}
if (preferWords && count < parts.length && parts[count] !== " " && space > 0) return space;
return count;
}

/** Only the active Todo title is free text. LF is private framing, not a token. */
export function activitySummary(tasks: unknown, columns = FALLBACK_COLUMNS): string | null {
if (!Array.isArray(tasks)) return null;
const title: unknown = tasks.find((task) => task?.status === "in_progress")?.title;
if (typeof title !== "string") return null;
const clipped = title.length > 4096;
// Bound sanitization/segmentation work, preserve ZWJ emoji but strip bidi controls.
/* eslint-disable no-control-regex */
const clean = stripTerminalSequences(title.slice(0, 4096))
.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200b-\u200c\u200e-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, " ")
.replace(/\s+/g, " ").trim();
/* eslint-enable no-control-regex */
if (!clean) return null;
const parts = Array.from(graphemes.segment(clean), ({ segment }) => segment);
// The bounded sample may end inside a grapheme; never publish that fragment.
if (clipped) parts.pop();
columns = Number.isFinite(columns) ? Math.max(1, Math.min(512, Math.floor(columns))) : FALLBACK_COLUMNS;
if (columns <= 2) return columns === 1 ? "…" : "◐…";
// Herdr caps each token at 80 scalars; count our icon/indent before its trim.
const whole = fit(parts, columns - 2, 252, 78, false);
if (!clipped && whole === parts.length) return `◐ ${parts.join("")}`;
// Reserve the second-row indent and ellipsis; private LF framing is not a token byte.
let firstCount = fit(parts, columns - 2, 247, 78);
if (!firstCount) return columns === 3 ? "◐…" : "◐ …";
const restFits = (count: number): boolean => {
const first = `◐ ${parts.slice(0, count).join("").trimEnd()}`;
const rest = parts.slice(count);
while (rest[0] === " ") rest.shift();
return fit(rest, columns - 2, 256 - Buffer.byteLength(first) - 2, 78, false) === rest.length;
};
// Prefer words unless splitting one would preserve the entire two-row title.
if (!clipped && !restFits(firstCount)) {
const hardCount = fit(parts, columns - 2, 247, 78, false);
if (restFits(hardCount)) firstCount = hardCount;
}
const first = `◐ ${parts.slice(0, firstCount).join("").trimEnd()}`;
const rest = parts.slice(firstCount);
while (rest[0] === " ") rest.shift();
const bytes = 256 - Buffer.byteLength(first) - 2; // Two-space indent.
let count = fit(rest, columns - 2, bytes, 78, false);
const overflow = clipped || count < rest.length;
if (overflow) count = fit(rest, columns - 3, bytes - 3, 77);
const second = rest.slice(0, count).join("").trimEnd();
return `${first}\n ${second}${overflow ? "…" : ""}`;
}

export function metadataArgs(pane: string, summary: string | null, seq: number): string[] {
const rows = summary?.split("\n") ?? [];
const tokens = ["summary", "summary2"].flatMap((token, index) => rows[index]
? ["--token", `${token}=${rows[index]}`] : ["--clear-token", token]);
return ["pane", "report-metadata", pane, "--source", "gentle:activity", "--agent", "pi",
"--seq", String(seq), "--ttl-ms", "30000", ...tokens];
}

export function metadataTransport(env: NodeJS.ProcessEnv): (summary: string | null, seq: number) => Promise<void> {
return (summary, seq) => new Promise((resolve) => {
// No shell, bounded output/time, and failure never blocks Pi's handlers.
execFile(env.HERDR_BIN_PATH ?? "herdr", metadataArgs(env.HERDR_PANE_ID!, summary, seq), {
env, timeout: 1500, maxBuffer: 4096, windowsHide: true,
}, () => resolve());
});
}

/** One in flight, one pending value; a clear supersedes queued active work. */
export class ActivityPublisher {
private pending: string | null | undefined;
private last: string | null | undefined;
private running = false;
private closed = false;
private cancel: (() => void) | undefined;
private seq = Date.now();
private readonly send: (summary: string | null, seq: number) => Promise<void>;
private readonly schedule: (fn: () => void) => () => void;
constructor(send: (summary: string | null, seq: number) => Promise<void>,
schedule: (fn: () => void) => () => void = (fn) => {
const timer = setTimeout(fn, 150);
timer.unref();
return () => clearTimeout(timer);
}) {
this.send = send;
this.schedule = schedule;
}

update(summary: string | null, force = false): void {
if (this.closed || (!force && (this.pending !== undefined
? summary === this.pending : !this.running && summary === this.last))) return;
this.pending = summary;
if (summary === null) {
this.cancel?.();
this.cancel = undefined;
if (!this.running) void this.flush();
return;
}
this.enqueue();
}
private enqueue(): void {
if (this.running || this.cancel || this.pending === undefined) return;
this.cancel = this.schedule(() => { this.cancel = undefined; void this.flush(); });
}
private async flush(): Promise<void> {
if (this.pending === undefined) return;
const summary = this.pending;
this.pending = undefined;
this.running = true;
try { await this.send(summary, ++this.seq); this.last = summary; }
catch { /* Best effort; a refresh retries active state. */ }
finally { this.running = false; this.enqueue(); }
}
close(): void {
if (this.closed) return;
this.update(null, true);
this.closed = true;
this.cancel?.(); this.cancel = undefined;
if (!this.running) void this.flush();
}
}
Loading
Loading