From fb2bf374cc3a6a5ab8bb26fe8d4394ce447e46c6 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Sat, 3 Oct 2026 12:30:09 +0200 Subject: [PATCH 1/2] feat(herdr): show active task summaries in the Agents sidebar Display the active Todo task with the Gentle icon across at most two rows, without duplicating agent state. Keep metadata bounded and clear it at lifecycle boundaries. Closes #1704 --- docs/readme-reference.md | 31 +++++ extensions/gentle-herdr-activity.ts | 81 +++++++++++++ lib/herdr-activity.ts | 167 +++++++++++++++++++++++++++ odd/tasks/herdr-agent-summary.md | 29 +++++ tests/gentle-herdr-activity.test.ts | 100 ++++++++++++++++ tests/gentle-shell-bin.test.ts | 17 +++ tests/herdr-activity.test.ts | 171 ++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+) create mode 100644 extensions/gentle-herdr-activity.ts create mode 100644 lib/herdr-activity.ts create mode 100644 odd/tasks/herdr-agent-summary.md create mode 100644 tests/gentle-herdr-activity.test.ts create mode 100644 tests/herdr-activity.test.ts diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 0b848e96b..46c0e641e 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -87,6 +87,37 @@ 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 optional continuation is indented by the reporter, +though Herdr may normalize leading whitespace. +Text wraps by display cells at word boundaries (long words split by grapheme), +with ellipsis only on the last overflowing row, or on an extremely narrow first +row. Both tokens together are capped at 256 UTF-8 bytes; ANSI/bidi controls are +removed. 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. + ## 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..9cac1bbe9 --- /dev/null +++ b/lib/herdr-activity.ts @@ -0,0 +1,167 @@ +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, preferring the previous space when a word would split. */ +function fit(parts: string[], columns: number, bytes: number): number { + let cells = 0; + let used = 0; + let count = 0; + let space = -1; + for (const part of parts) { + const width = visibleWidth(part); + const size = Buffer.byteLength(part); + if (cells + width > columns || used + size > bytes) break; + if (part === " ") space = count; + cells += width; + used += size; + count++; + } + if (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\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 ? "…" : "◐…"; + const whole = fit(parts, columns - 2, 252); + if (!clipped && whole === parts.length) return `◐ ${parts.join("")}`; + // Reserve a second-row indent, separator and ellipsis in the shared byte budget. + const firstCount = fit(parts, columns - 2, 246); + if (!firstCount) return columns === 3 ? "◐…" : "◐ …"; + const first = `◐ ${parts.slice(0, firstCount).join("").trimEnd()}`; + const rest = parts.slice(firstCount); + while (rest[0] === " ") rest.shift(); + const bytes = 256 - Buffer.byteLength(first) - 3; // LF + two-space indent. + let count = fit(rest, columns - 2, bytes); + const overflow = clipped || count < rest.length; + if (overflow) count = fit(rest, columns - 3, bytes - 3); + 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("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 && summary === (this.pending === undefined ? this.last : this.pending))) 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..0fd2e5eeb --- /dev/null +++ b/odd/tasks/herdr-agent-summary.md @@ -0,0 +1,29 @@ +# 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. + +## 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. 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..deaa1a448 --- /dev/null +++ b/tests/herdr-activity.test.ts @@ -0,0 +1,171 @@ +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("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("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("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(); } +}); From 76b46e33eab1a50af47ff331b477c5c1107d8389 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Sat, 3 Oct 2026 13:11:05 +0200 Subject: [PATCH 2/2] fix(herdr): honor token limits and preserve summary updates Address PR #1705 review findings: enforce per-token character limits, retain clears during in-flight sends, use HERDR_BIN_PATH, sanitize Arabic Letter Mark and avoid unnecessary truncation. --- docs/readme-reference.md | 19 +++++---- lib/herdr-activity.ts | 42 +++++++++++++------ odd/tasks/herdr-agent-summary.md | 7 ++++ tests/herdr-activity.test.ts | 72 ++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 20 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 46c0e641e..f3c569447 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -98,12 +98,16 @@ missing Herdr/socket contexts do not publish. The packaged extension loads throu 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 optional continuation is indented by the reporter, -though Herdr may normalize leading whitespace. -Text wraps by display cells at word boundaries (long words split by grapheme), -with ellipsis only on the last overflowing row, or on an extremely narrow first -row. Both tokens together are capped at 256 UTF-8 bytes; ANSI/bidi controls are -removed. Pathological oversized graphemes may be replaced by an ellipsis. +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. @@ -116,7 +120,8 @@ Source `gentle:activity` owns only `summary`/`summary2`, not lifecycle. Updates 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. +bridge remains lifecycle authority. Reports invoke `HERDR_BIN_PATH` when supplied, +otherwise `herdr` from `PATH`, without a shell. ## Navigation diff --git a/lib/herdr-activity.ts b/lib/herdr-activity.ts index 9cac1bbe9..e9978c9f0 100644 --- a/lib/herdr-activity.ts +++ b/lib/herdr-activity.ts @@ -43,22 +43,25 @@ export function createActivityColumns(socket: string, now: () => number = Date.n }; } -/** Fit whole graphemes, preferring the previous space when a word would split. */ -function fit(parts: string[], columns: number, bytes: number): number { +/** 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); - if (cells + width > columns || used + size > bytes) break; + 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 (count < parts.length && parts[count] !== " " && space > 0) return space; + if (preferWords && count < parts.length && parts[count] !== " " && space > 0) return space; return count; } @@ -71,7 +74,7 @@ export function activitySummary(tasks: unknown, columns = FALLBACK_COLUMNS): str // 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\u200b-\u200c\u200e-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, " ") + .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; @@ -80,18 +83,30 @@ export function activitySummary(tasks: unknown, columns = FALLBACK_COLUMNS): str 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 ? "…" : "◐…"; - const whole = fit(parts, columns - 2, 252); + // 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 a second-row indent, separator and ellipsis in the shared byte budget. - const firstCount = fit(parts, columns - 2, 246); + // 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) - 3; // LF + two-space indent. - let count = fit(rest, columns - 2, bytes); + 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); + if (overflow) count = fit(rest, columns - 3, bytes - 3, 77); const second = rest.slice(0, count).join("").trimEnd(); return `${first}\n ${second}${overflow ? "…" : ""}`; } @@ -107,7 +122,7 @@ export function metadataArgs(pane: string, summary: string | null, seq: number): 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("herdr", metadataArgs(env.HERDR_PANE_ID!, summary, seq), { + execFile(env.HERDR_BIN_PATH ?? "herdr", metadataArgs(env.HERDR_PANE_ID!, summary, seq), { env, timeout: 1500, maxBuffer: 4096, windowsHide: true, }, () => resolve()); }); @@ -134,7 +149,8 @@ export class ActivityPublisher { } update(summary: string | null, force = false): void { - if (this.closed || (!force && summary === (this.pending === undefined ? this.last : this.pending))) return; + if (this.closed || (!force && (this.pending !== undefined + ? summary === this.pending : !this.running && summary === this.last))) return; this.pending = summary; if (summary === null) { this.cancel?.(); diff --git a/odd/tasks/herdr-agent-summary.md b/odd/tasks/herdr-agent-summary.md index 0fd2e5eeb..4144594d0 100644 --- a/odd/tasks/herdr-agent-summary.md +++ b/odd/tasks/herdr-agent-summary.md @@ -14,6 +14,7 @@ User explicitly approved 500–600 total authored lines (previous estimate 383), - [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: @@ -27,3 +28,9 @@ Geometry is persisted, not instantaneous: snapshot may lag resize five seconds a 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/herdr-activity.test.ts b/tests/herdr-activity.test.ts index deaa1a448..ef7dda9f1 100644 --- a/tests/herdr-activity.test.ts +++ b/tests/herdr-activity.test.ts @@ -43,6 +43,44 @@ test("Unicode uses display cells and keeps accents, CJK and emoji graphemes inta 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)!; @@ -77,6 +115,24 @@ test("publisher serializes latest-only updates, clear, refresh and transport fai 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> = []; @@ -107,6 +163,22 @@ test("CLI transport uses only a bounded shell-free stub and tolerates offline fa } 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"]);