diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 0b848e96b..f3c569447 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -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) diff --git a/extensions/gentle-herdr-activity.ts b/extensions/gentle-herdr-activity.ts new file mode 100644 index 000000000..fe5e367b4 --- /dev/null +++ b/extensions/gentle-herdr-activity.ts @@ -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; + 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(); diff --git a/lib/herdr-activity.ts b/lib/herdr-activity.ts new file mode 100644 index 000000000..e9978c9f0 --- /dev/null +++ b/lib/herdr-activity.ts @@ -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 { + 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; + private readonly schedule: (fn: () => void) => () => void; + constructor(send: (summary: string | null, seq: number) => Promise, + 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 { + 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(); + } +} diff --git a/odd/tasks/herdr-agent-summary.md b/odd/tasks/herdr-agent-summary.md new file mode 100644 index 000000000..4144594d0 --- /dev/null +++ b/odd/tasks/herdr-agent-summary.md @@ -0,0 +1,36 @@ +# Herdr active-agent summary + +Locator: `odd/tasks/herdr-agent-summary.md`; branch `feat/herdr-agent-summary`. + +## Objective / why +Expose the active Todo title in Herdr's Agents sidebar without another model call or changing lifecycle authority. + +## Scope / constraints +User-approved refinement supersedes the MVP phase/tool projection: only `◐` plus `in_progress` Todo title, at most two display-cell-bounded rows. Source `gentle:activity` owns `summary`/`summary2`, not semantic state. No prompts, arguments, output, notes, fallback tools or ODD labels. No personal config, managed bridge, live Herdr, reload, sync, dependencies, commits, pushes or PRs. One sequential writer; independent parent verification follows. +User explicitly approved 500–600 total authored lines (previous estimate 383), without code golf. Package worktree and first config row were already selected; parent owns adding the second row and any activation. + +## Tasks / acceptance +- [x] T1 — Pure task-only projection: malformed/empty clears, sanitized Unicode, words/graphemes, at most two rows, last-row ellipsis and combined 256-byte budget. +- [x] T2 — Cached geometry: bounded 256-KiB session snapshot beside an absolute socket path, root width minus five, five-second cache, conservative 24-column fallback; injected columns in extension tests. +- [x] T3 — Root TUI extension: no phase/tool dependency, serialized latest-only transport, timeout/offline containment, TTL 30s/refresh 10s, atomic unused-row and boundary cleanup. +- [x] T4 — Document separate rows `['$summary'], ['$summary2']`, persisted resize lag and unsupported-layout limitation; focused checks pass. +- [x] T5 — PR #1705 corrections: enforce each token's 80-scalar cap before wrapping, preserve non-forced clear during active transport, honor `HERDR_BIN_PATH`, remove Arabic Letter Mark, and avoid word-boundary truncation when a hard break preserves the full title. Document the two-space prefix and normalization; isolated serial regressions pass. + +## Verification +Strict TDD explicitly activated by parent. Exact isolated serial runner: +`env -u HERDR_ENV -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_BIN_PATH NODE_OPTIONS=--max-old-space-size=1024 node --experimental-strip-types --test --test-concurrency=1 tests/herdr-activity.test.ts tests/gentle-herdr-activity.test.ts`. +RED before production: 4/14 passed; 10 intended failures exposed old phase/tool fallback, old signature, missing geometry and missing second-token clear. GREEN: 14/14 passed. Triangulation RED: 14/15 passed, malformed successful Todo retained `◐ valid`; corrected snapshot handling then GREEN: 15/15 passed. Transport and snapshot IO were stubbed only, never live Herdr or real user data. Unicode, growth-after-stat bounds, cache/fallback, long-to-short atomic clear, root/children, ownership, offline and ordering covered. +Prior parent evidence, not rerun here: focused launcher/child checks passed, generated-runtime check passed (8 runtime checks); type ratchet has pre-existing 223 vs 200 diagnostics. This TS refinement has clean focused editor diagnostics. No new type-baseline claim. + +## Progress / next step +Reconciled cancelled-writer bytes: production was still phase/tool MVP; partial RED tests existed. Preserved unrelated launcher-test changes and node_modules symlink. Final authored count after the nonblocking-open correction: 578 lines in the six scoped surfaces (548 new-file lines + 30 documentation additions); 595 including 17 preserved launcher-test additions. Within the approved 500–600 total. +Geometry is persisted, not instantaneous: snapshot may lag resize five seconds and cache another five. Fallback/layouts cannot guarantee exact live sidebar width. Pathological graphemes exceeding the shared byte budget become ellipsis rather than leaking combining marks. +Native review is unavailable without managed-asset work; no sync attempted. Parent owns independent verification, second config row and delivery. Engram mirror remains parent-owned: no validated memory project was supplied. + +Independent FIFO finding resolved: numeric `O_RDONLY | (O_NONBLOCK ?? 0)` avoids waiting for FIFO writers (including symlink targets), then existing fstat rejects non-files and finally closes the descriptor; missing platform constants are omitted for Windows compatibility. Contract regression (no live FIFO/Herdr): RED 15/16, expected numeric flags but observed `"r"`; GREEN 16/16 with the exact isolated serial runner above. Regression verifies fallback 24, no non-file read and exactly one close; existing 256-KiB bound, five-second cache and Unicode/two-row/256-byte/icon checks remain green. + +## PR #1705 correction evidence +User authorized the five validated fixes plus the documentation precision change, retaining cached `delivery_strategy=single-pr`; no chain or size-exception choice was added. Parent reported a 16/16 read-only baseline and green prior CI; those results did not invalidate the counterexamples. Correction forecast was 80–150 authored lines, without code golf. +With the exact isolated serial runner above, five new regressions first produced RED 16/21 (five intended failures): 100 ASCII characters stayed in one overlong token, a fitting hard-break title was ellipsized, U+061C survived, a non-forced clear was lost after prior clear plus in-flight activity, and transport ignored the explicit executable. Minimal corrections produced GREEN 21/21. +Initial GREEN also exercised UTF-16-versus-scalar emoji and low-width combining-grapheme cases. Subsequent triangulation added exact 80-scalar row boundaries and a full title at exactly 256 combined token bytes. The latter exposed private LF incorrectly consuming a token byte: RED 20/21, then GREEN 21/21 after excluding framing from the shared budget. Prefixes and ellipsis still count, graphemes remain intact, and actual overflow still truncates only the final row. Non-ASCII wrapping, no-task/root/child/TTL/geometry guards, serialization, offline stubs and PATH fallback remain covered. +Only projection/publisher/transport, focused tests and reference/task docs changed; geometry/cache/FIFO handling, extension lifecycle and managed bridge were not edited. All transport and geometry checks used stubs; no live Herdr, reload, config, sync or delivery action occurred. Type ratchet and broad suites were not rerun; the previously reported 223-vs-200 SDK diagnostics remain historical, not a new baseline. The correction patch is 120 additions plus 20 deletions across four files; isolated `git diff --check` passed. The pre-existing untracked dependency symlink was preserved. Parent owns independent verification, Engram mirror, commit/push/replies/resolution and any activation; there is no post-change UI claim. diff --git a/tests/gentle-herdr-activity.test.ts b/tests/gentle-herdr-activity.test.ts new file mode 100644 index 000000000..0b55c446d --- /dev/null +++ b/tests/gentle-herdr-activity.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { createHerdrActivityExtension } from "../extensions/gentle-herdr-activity.ts"; +import { oddPhaseRegistry } from "../lib/odd-phase.ts"; +import { metadataArgs } from "../lib/herdr-activity.ts"; + +type Handler = (event: any, ctx: ExtensionContext) => void; +function harness(overrides: NodeJS.ProcessEnv = {}, socket = true) { + const handlers = new Map(); + const values: Array = []; + const reports: string[][] = []; + let tick: (() => void) | undefined; + let stopped = false; + let now = 1000; + let columns = 30; + const options = { + env: { HERDR_ENV: "1", HERDR_PANE_ID: "pane", HERDR_SOCKET_PATH: "/socket", ...overrides }, + isSocket: () => socket, + now: () => now, + columns: () => columns, + send: async (summary: string | null, seq: number) => { + values.push(summary); reports.push(metadataArgs("pane", summary, seq)); + }, + watch: (fn: () => void) => { tick = fn; return () => { stopped = true; }; }, + }; + createHerdrActivityExtension(options)({ on: (name: string, fn: Handler) => handlers.set(name, fn) } as unknown as ExtensionAPI); + const ctx = { mode: "tui", hasUI: true, sessionManager: { getSessionId: () => "root", getBranch: () => [] } } as unknown as ExtensionContext; + return { handlers, values, reports, ctx, tick: () => tick?.(), stopped: () => stopped, + advance: (ms: number) => { now += ms; }, columns: (value: number) => { columns = value; }, + fire: (name: string, event = {}, context = ctx) => handlers.get(name)?.(event, context) }; +} +const wait = () => new Promise((resolve) => setTimeout(resolve, 180)); +const todo = (title?: string) => ({ toolName: "todo", result: { details: { gentleTodo: { + tasks: title === undefined ? [] : [{ title, status: "in_progress" }], +} } } }); +test("tools and failed Todo snapshots never supply fallback summaries", async () => { + const h = harness(); h.fire("session_start"); h.fire("agent_start"); + h.fire("tool_execution_start", { toolName: "read", args: { path: "secret" } }); + h.tick(); await wait(); assert.equal(h.values.at(-1), null); + h.fire("tool_execution_end", { toolName: "read" }); + h.tick(); await wait(); assert.equal(h.values.at(-1), null); + h.fire("tool_execution_end", { toolName: "todo", isError: true, + result: { details: { gentleTodo: { tasks: [{ title: "failed", status: "in_progress" }] } } } }); + h.tick(); await wait(); assert.equal(h.values.at(-1), null); + h.fire("session_shutdown"); +}); +test("children, missing Herdr and socket, and non-TUI never publish", async () => { + for (const h of [harness({ GENTLE_PI_AGENTS_CHILD: "1" }), harness({ HERDR_ENV: "0" }), harness({ HERDR_PANE_ID: "" }), harness({}, false)]) { + h.fire("session_start"); h.fire("agent_start"); await wait(); assert.deepEqual(h.values, []); + } + const h = harness(); + h.fire("session_start", {}, { ...h.ctx, mode: "rpc" } as ExtensionContext); + h.fire("agent_start"); await wait(); assert.deepEqual(h.values, []); +}); + +test("root uses only Todo despite ODD; TTL, idle, ownership and cleanup remain guarded", async () => { + const h = harness(); h.fire("session_start"); h.fire("agent_start"); + oddPhaseRegistry.report("root", "exploring"); + h.fire("tool_execution_end", { toolName: "todo", result: { details: { gentleTodo: { tasks: [{ title: "Revisar integración Herdr", status: "in_progress" }] } } } }); + h.tick(); await wait(); assert.equal(h.values.at(-1), "◐ Revisar integración Herdr"); + const count = h.values.length; + h.tick(); await wait(); assert.equal(h.values.length, count); + h.advance(10000); h.tick(); await wait(); assert.equal(h.values.length, count + 1); + h.fire("agent_settled", {}, { ...h.ctx, sessionManager: { getSessionId: () => "child" } } as ExtensionContext); + assert.notEqual(h.values.at(-1), null); + h.fire("agent_settled"); await wait(); assert.equal(h.values.at(-1), null); + h.fire("agent_start"); h.tick(); await wait(); + h.fire("session_start"); await wait(); assert.equal(h.values.at(-1), null); + h.fire("session_shutdown"); await wait(); assert.ok(h.stopped()); + oddPhaseRegistry.clear("root"); +}); + +test("width changes reproject and long-to-short/no-task/idle clear both rows together", async () => { + const h = harness(); h.fire("session_start"); h.fire("agent_start"); h.columns(12); + h.fire("tool_execution_end", todo("one two three four")); h.tick(); await wait(); + assert.equal(h.values.at(-1), "◐ one two\n three four"); + h.columns(30); h.tick(); await wait(); + assert.equal(h.values.at(-1), "◐ one two three four"); + h.columns(12); h.tick(); await wait(); + h.fire("tool_execution_end", todo("short")); h.tick(); await wait(); + assert.equal(h.values.at(-1), "◐ short"); + assert.deepEqual(h.reports.at(-1)?.slice(-4), ["--token", "summary=◐ short", "--clear-token", "summary2"]); + h.fire("tool_execution_end", todo()); h.tick(); await wait(); + assert.equal(h.values.at(-1), null); + h.fire("tool_execution_end", todo("active")); h.tick(); await wait(); + h.fire("session_tree"); await wait(); assert.equal(h.values.at(-1), null); + assert.deepEqual(h.reports.at(-1)?.slice(-4), ["--clear-token", "summary", "--clear-token", "summary2"]); + h.fire("session_shutdown"); +}); + +test("malformed successful Todo snapshots clear previous activity, unlike failed snapshots", async () => { + const h = harness(); h.fire("session_start"); h.fire("agent_start"); + for (const tasks of [null, {}, [{ title: 42, status: "in_progress" }]]) { + h.fire("tool_execution_end", todo("valid")); h.tick(); await wait(); + h.fire("tool_execution_end", { toolName: "todo", result: { details: { gentleTodo: { tasks } } } }); + h.tick(); await wait(); assert.equal(h.values.at(-1), null); + } + h.fire("session_shutdown"); +}); diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index a3f972837..5ff2475c9 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -30,6 +30,23 @@ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url); const binPath = fileURLToPath(binUrl); const packageRoot = dirname(dirname(binPath)); +test("Herdr activity is discoverable through the isolated launcher package", async () => { + const { buildPiInvocation } = await import("../lib/gentle-shell-launcher.ts"); + const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); + assert.ok(manifest.pi.extensions.includes("./extensions")); + assert.ok(existsSync(join(packageRoot, "extensions", "gentle-herdr-activity.ts"))); + for (const takeOver of [false, true]) { + const invocation = buildPiInvocation({ + runtime: { kind: "path", command: "fake-pi", args: [] }, + home: { mode: "isolated", source: "default", dir: "/fake/home" }, + packageRoot, declaration: undefined, takeOver, otherPackagePaths: [], + passthrough: [], baseEnv: {}, homedir: "/fake", + }); + assert.ok(invocation.args.includes(packageRoot)); + assert.equal(invocation.env.PI_CODING_AGENT_DIR, "/fake/home"); + } +}); + test("real adjacent Pi resolves through its public entry without PATH or a runtime override", (t) => { const f = fixture(t); const result = spawnSync(process.execPath, [binPath, "--version"], { diff --git a/tests/herdr-activity.test.ts b/tests/herdr-activity.test.ts new file mode 100644 index 000000000..ef7dda9f1 --- /dev/null +++ b/tests/herdr-activity.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import childProcess from "node:child_process"; +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { activitySummary, ActivityPublisher, createActivityColumns, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts"; + +const tasks = [{ title: "Revisar integración Herdr", status: "in_progress" }]; +const project = (title: string, columns = 30) => activitySummary([{ title, status: "in_progress" }], columns); + +test("summary contains only the icon and sanitized active Todo title", () => { + assert.equal(activitySummary(tasks, 30), "◐ Revisar integración Herdr"); + assert.equal(project("\x1b[31munsafe\x1b[0m\n\u202e title"), "◐ unsafe title"); + assert.equal(project("\x1b]8;;https://private\x07safe\x1b]8;;\x07"), "◐ safe"); + assert.equal(project("\x1b_private\x1b\\safe"), "◐ safe"); +}); + +test("absent, completed, malformed or sanitized-empty active titles clear the summary", () => { + for (const entries of [null, {}, [], [{ title: "pending", status: "pending" }], + [{ title: "done", status: "completed" }], [{ title: 42, status: "in_progress" }], + [{ title: "\x1b[31m\x1b[0m\n\u202e", status: "in_progress" }], [null]]) { + assert.equal(activitySummary(entries), null); + } +}); + +test("two rows preserve words and all fitting text; only the last row truncates", () => { + assert.equal(project("one two three four", 12), "◐ one two\n three four"); + const long = project("one two three four five six", 12)!; + assert.equal(long, "◐ one two\n three…"); + assert.equal(project("abcdefghijklmnop", 10), "◐ abcdefgh\n ijklmnop"); + assert.equal(project("abcdefghijk", 3), "◐ a\n …"); + assert.equal(project("abc", 2), "◐…"); +}); + +test("Unicode uses display cells and keeps accents, CJK and emoji graphemes intact", () => { + for (const title of ["café a\u0301 mañana", "漢字漢字漢字漢字", "👩🏽‍💻 👨‍👩‍👧‍👦 😀", "🇪🇸🇯🇵🇫🇷"]) { + const text = project(title, 10)!; + assert.ok(text.split("\n").length <= 2); + for (const row of text.split("\n")) assert.ok(visibleWidth(row) <= 10, row); + assert.equal(text.replace(/^◐ /, "").replace("\n ", "").replace(/ /g, ""), title.replace(/ /g, "")); + } + assert.equal(project("👩🏽‍💻".repeat(10), 8), "◐ 👩🏽‍💻👩🏽‍💻👩🏽‍💻\n 👩🏽‍💻👩🏽‍💻…"); +}); + +test("tokens fit 80 Unicode scalars before wrapping, including prefixes and ellipsis", () => { + assert.equal(project("x".repeat(100), 105), `◐ ${"x".repeat(78)}\n ${"x".repeat(22)}`); + assert.equal(project("😀".repeat(50), 105), `◐ ${"😀".repeat(50)}`, "count scalars, not UTF-16 units"); + assert.equal(project("x".repeat(156), 512), `◐ ${"x".repeat(78)}\n ${"x".repeat(78)}`); + assert.equal(project("x".repeat(157), 512), `◐ ${"x".repeat(78)}\n ${"x".repeat(77)}…`); + assert.equal(project("😀".repeat(56) + "x".repeat(26), 200), + `◐ ${"😀".repeat(56)}${"x".repeat(22)}\n xxxx`, "private LF is not part of the 256-byte token budget"); + const combining = "a\u0301\u0302".repeat(40); + assert.equal(project(combining, 105), `◐ ${"a\u0301\u0302".repeat(26)}\n ${"a\u0301\u0302".repeat(14)}`); + for (const title of ["x".repeat(200), combining.repeat(2), "😀".repeat(100), + "a" + "\u0301".repeat(80), "👩🏽‍💻".repeat(100)]) { + const text = project(title, 105)!; + assert.ok(text.endsWith("…"), "actual capacity overflow needs ellipsis"); + assert.ok(text.split("\n").length <= 2); + for (const row of text.split("\n")) { + assert.ok(Array.from(row).length <= 80, "each raw token includes its prefix and ellipsis"); + assert.ok(visibleWidth(row) <= 105); + } + assert.ok(Buffer.byteLength(text.replace("\n", "")) <= 256); + } +}); + +test("word preference yields to a hard break only to avoid unnecessary ellipsis", () => { + assert.equal(project("a bcdefghijkl", 10), "◐ a bcdefg\n hijkl"); + assert.equal(project("a 漢字漢字漢", 10), "◐ a 漢字漢\n 字漢"); + assert.equal(project("one two three four", 12), "◐ one two\n three four"); + assert.equal(project(`a ${"b".repeat(100)}`, 80), `◐ a ${"b".repeat(76)}\n ${"b".repeat(24)}`); + const combined = project(`a ${"😀".repeat(70)}`, 105)!; + assert.ok(combined.endsWith("…"), "hard breaks cannot bypass the shared byte budget"); + assert.ok(Buffer.byteLength(combined.replace("\n", "")) <= 256); + for (const row of combined.split("\n")) assert.ok(Array.from(row).length <= 80); +}); + +test("sanitization removes Arabic Letter Mark without removing emoji ZWJ", () => { + assert.equal(project("abc\u061c123"), "◐ abc 123"); + assert.equal(project("👩🏽‍💻\u061c ok"), "◐ 👩🏽‍💻 ok"); +}); + +test("UTF-8 budget covers both tokens including prefixes and ellipsis", () => { + for (const title of ["😀".repeat(500), "👩🏽‍💻".repeat(500), "a" + "\u0301".repeat(10000), "x".repeat(10000)]) { + const text = project(title, 200)!; + assert.ok(Buffer.byteLength(text.replace("\n", "")) <= 256); + assert.ok(text.endsWith("…")); + assert.doesNotMatch(text, /[\x00-\x09\x0b-\x1f\x7f\u202e]/); + assert.ok(text.split("\n").length <= 2); + } +}); + +test("publisher serializes latest-only updates, clear, refresh and transport failures", async () => { + const calls: Array<{ summary: string | null; seq: number }> = []; + const queue: Array<() => void> = []; + let release: (() => void) | undefined; + const publisher = new ActivityPublisher(async (summary, seq) => { + calls.push({ summary, seq }); + if (calls.length === 1) await new Promise((resolve) => { release = resolve; }); + if (summary === "fail") throw new Error("offline"); + }, (fn) => { queue.push(fn); return () => {}; }); + publisher.update("old"); publisher.update("first"); queue.shift()!(); + publisher.update("second"); publisher.update("latest"); + release!(); await new Promise(setImmediate); + queue.shift()!(); await new Promise(setImmediate); + assert.deepEqual(calls.map((call) => call.summary), ["first", "latest"]); + publisher.update("latest"); assert.equal(queue.length, 0); + publisher.update("latest", true); queue.shift()!(); await new Promise(setImmediate); + publisher.update("fail"); queue.shift()!(); await new Promise(setImmediate); + publisher.update(null); await new Promise(setImmediate); + assert.equal(calls.at(-1)?.summary, null); + assert.ok(calls.every((call, index) => index === 0 || call.seq > calls[index - 1]!.seq)); + publisher.close(); publisher.update("ignored"); assert.equal(queue.length, 0); + await new Promise(setImmediate); +}); + +test("a non-forced clear supersedes active work in flight after a previous clear", async () => { + const calls: Array = []; + const queue: Array<() => void> = []; + let release!: () => void; + const publisher = new ActivityPublisher(async (summary) => { + calls.push(summary); + if (summary === "active") await new Promise((resolve) => { release = resolve; }); + }, (fn) => { queue.push(fn); return () => {}; }); + publisher.update(null); await new Promise(setImmediate); + publisher.update("active"); queue.shift()!(); + publisher.update(null); publisher.update(null); + release(); await new Promise(setImmediate); + assert.equal(queue.length, 1, "one latest clear must be queued, not deduplicated against stale last"); + queue.shift()!(); await new Promise(setImmediate); + assert.deepEqual(calls, [null, "active", null], "clear happens before close or TTL expiry"); + publisher.close(); await new Promise(setImmediate); +}); + +test("shutdown supersedes queued activity even while a send is in flight", async () => { + const calls: Array = []; + const queue: Array<() => void> = []; + let release!: () => void; + const publisher = new ActivityPublisher(async (summary) => { + calls.push(summary); + if (calls.length === 1) await new Promise((resolve) => { release = resolve; }); + }, (fn) => { queue.push(fn); return () => {}; }); + publisher.update("active"); queue.shift()!(); publisher.update("stale"); + publisher.close(); publisher.update("ignored"); release(); + await new Promise(setImmediate); queue.shift()!(); await new Promise(setImmediate); + assert.deepEqual(calls, ["active", null]); +}); + +test("CLI transport uses only a bounded shell-free stub and tolerates offline failure", async (t) => { + const calls: unknown[][] = []; + const stub = t.mock.method(childProcess, "execFile", (...args: unknown[]) => { + calls.push(args); + (args[3] as (error: Error) => void)(new Error("offline")); + }); + syncBuiltinESMExports(); + try { + const env = { HERDR_ENV: "1", HERDR_SOCKET_PATH: "/fake", HERDR_PANE_ID: "fake-pane" }; + await metadataTransport(env)("◐ long\n title", 7); + assert.equal(calls[0]?.[0], "herdr"); + assert.deepEqual(calls[0]?.[1], metadataArgs("fake-pane", "◐ long\n title", 7)); + assert.deepEqual(calls[0]?.[2], { env, timeout: 1500, maxBuffer: 4096, windowsHide: true }); + } finally { stub.mock.restore(); syncBuiltinESMExports(); } +}); + +test("CLI transport honors an explicit Herdr binary path without shell interpretation", async (t) => { + const calls: unknown[][] = []; + const stub = t.mock.method(childProcess, "execFile", (...args: unknown[]) => { + calls.push(args); + (args[3] as (error: Error | null) => void)(null); + }); + syncBuiltinESMExports(); + try { + const env = { HERDR_BIN_PATH: "/fake/custom herdr/bin/herdr", HERDR_PANE_ID: "pane", PATH: "/fake/bin" }; + await metadataTransport(env)("◐ task", 8); + assert.equal(calls[0]?.[0], env.HERDR_BIN_PATH); + assert.deepEqual(calls[0]?.[1], metadataArgs("pane", "◐ task", 8)); + assert.deepEqual(calls[0]?.[2], { env, timeout: 1500, maxBuffer: 4096, windowsHide: true }); + } finally { stub.mock.restore(); syncBuiltinESMExports(); } +}); + +test("one metadata report sets or clears both tokens without changing semantic state", () => { + const base = ["pane", "report-metadata", "pane", "--source", "gentle:activity", "--agent", "pi", "--seq", "42", "--ttl-ms", "30000"]; + assert.deepEqual(metadataArgs("pane", null, 42), [...base, "--clear-token", "summary", "--clear-token", "summary2"]); + assert.deepEqual(metadataArgs("pane", "◐ long\n title", 42), [...base, "--token", "summary=◐ long", "--token", "summary2= title"]); + assert.deepEqual(metadataArgs("pane", "◐ short", 42), [...base, "--token", "summary=◐ short", "--clear-token", "summary2"]); +}); + +test("geometry opens nonblocking and closes non-files without reading", (t) => { + let flags: unknown; + const stubs = [ + t.mock.method(fs, "openSync", (_path: string, mode: unknown) => { flags = mode; return 9; }), + t.mock.method(fs, "fstatSync", () => ({ size: 0, isFile: () => false })), + t.mock.method(fs, "readSync", () => { throw new Error("must not read a non-file"); }), + t.mock.method(fs, "closeSync", () => {}), + ]; + syncBuiltinESMExports(); + try { + assert.equal(createActivityColumns("/fake/herdr.sock")(), 24); + assert.equal(stubs[2]!.mock.callCount(), 0); + assert.deepEqual(stubs[3]!.mock.calls[0]!.arguments, [9]); + assert.equal(stubs[3]!.mock.callCount(), 1); + assert.equal(typeof flags, "number", "open must use numeric nonblocking flags"); + assert.equal(flags, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); + } finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); } +}); + +test("geometry is cached, local/named, bounded and fail-safe without reading user data", (t) => { + let now = 0; + let body = '{"sidebar_width":35}'; + let grew = false; + const paths: string[] = []; + const stubs = [ + t.mock.method(fs, "openSync", (path: string) => { paths.push(path); return 9; }), + t.mock.method(fs, "fstatSync", () => ({ size: grew ? 1 : Buffer.byteLength(body), isFile: () => true })), + t.mock.method(fs, "readSync", (_fd: number, buffer: Buffer) => { + assert.equal(buffer.length, 262145); + return grew ? buffer.length : buffer.write(body); + }), + t.mock.method(fs, "closeSync", () => {}), + ]; + syncBuiltinESMExports(); + try { + const columns = createActivityColumns("/fake/named/herdr.sock", () => now); + assert.equal(columns(), 30); body = '{"sidebar_width":25}'; + assert.equal(columns(), 30); assert.equal(paths.length, 1); + now = 5000; assert.equal(columns(), 20); + assert.deepEqual(paths, ["/fake/named/session.json", "/fake/named/session.json"]); + for (const invalid of ["{", "null", '{"sidebar_width":"35"}', '{"sidebar_width":-1}', + '{"sidebar_width":35.5}', '{"sidebar_width":9999}', "x".repeat(262145)]) { + body = invalid; now += 5000; assert.equal(columns(), 24); + } + assert.equal(stubs[2]!.mock.callCount(), paths.length - 1, "oversized fstat must skip read"); + grew = true; // Simulate a snapshot growing after fstat. + now += 5000; assert.equal(columns(), 24); + assert.equal(stubs[3]!.mock.callCount(), paths.length, "every opened descriptor closes"); + stubs[0]!.mock.mockImplementation(() => { throw new Error("missing"); }); + now += 5000; assert.equal(columns(), 24); + const before = paths.length; + assert.equal(createActivityColumns("relative.sock")(), 24); + assert.equal(paths.length, before, "relative paths must not read arbitrary snapshots"); + } finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); } +});