@tpsdev-ai/flair 0.8.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * REM nightly scheduler — platform-native scheduler install/uninstall.
3
+ *
4
+ * Per FLAIR-NIGHTLY-REM § 3 (`flair rem nightly enable|disable`). Renders
5
+ * launchd plist (macOS) or systemd timer+service (Linux) from templates,
6
+ * deploys a shim script to `~/.flair/bin/flair-rem-nightly`, and loads the
7
+ * job into the user-session scheduler.
8
+ *
9
+ * Templates use `{{KEY}}` placeholders — single-pass substitution. The full
10
+ * placeholder set is enumerated in `interface SchedulerSubstitutions` so
11
+ * adding a new key requires touching both this module and the template.
12
+ *
13
+ * No daemon code lives here — the scheduler invokes the shim, the shim
14
+ * invokes `flair rem nightly run-once`, the runner module does the work.
15
+ */
16
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from "node:fs";
17
+ import { resolve, dirname } from "node:path";
18
+ import { homedir, platform } from "node:os";
19
+ import { spawnSync } from "node:child_process";
20
+ import { fileURLToPath } from "node:url";
21
+ export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
22
+ export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
23
+ export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
24
+ export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.timer");
25
+ export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
26
+ function detectPlatform(override) {
27
+ if (override)
28
+ return override;
29
+ const p = platform();
30
+ if (p === "darwin")
31
+ return "darwin";
32
+ if (p === "linux")
33
+ return "linux";
34
+ throw new Error(`unsupported platform for REM nightly scheduler: ${p} (only darwin and linux)`);
35
+ }
36
+ function defaultTemplateRoot() {
37
+ // Templates live alongside dist/ in the published package and alongside
38
+ // src/rem/ in the source tree. Walk up from this file until we find
39
+ // a directory containing templates/.
40
+ const here = dirname(fileURLToPath(import.meta.url));
41
+ const candidates = [
42
+ resolve(here, "..", "..", "templates"),
43
+ resolve(here, "..", "..", "..", "templates"),
44
+ resolve(here, "..", "templates"),
45
+ ];
46
+ for (const c of candidates) {
47
+ if (existsSync(c))
48
+ return c;
49
+ }
50
+ throw new Error(`unable to locate templates directory (looked in: ${candidates.join(", ")})`);
51
+ }
52
+ export function renderTemplate(text, subs) {
53
+ return text.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
54
+ const value = subs[key];
55
+ if (value === undefined)
56
+ throw new Error(`unknown template placeholder: ${key}`);
57
+ return String(value);
58
+ });
59
+ }
60
+ export function readTemplate(rootDir, relativePath) {
61
+ const full = resolve(rootDir, relativePath);
62
+ if (!existsSync(full)) {
63
+ throw new Error(`template not found: ${full}`);
64
+ }
65
+ return readFileSync(full, "utf-8");
66
+ }
67
+ /**
68
+ * Validates the hour:minute schedule. Throws on invalid input rather than
69
+ * silently coercing — surface bad input at the install boundary.
70
+ */
71
+ function validateSchedule(hour, minute) {
72
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
73
+ throw new Error(`hour must be an integer 0-23, got ${hour}`);
74
+ }
75
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
76
+ throw new Error(`minute must be an integer 0-59, got ${minute}`);
77
+ }
78
+ }
79
+ function buildSubstitutions(opts, shimPath, flairBin) {
80
+ validateSchedule(opts.hour, opts.minute);
81
+ if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
82
+ throw new Error(`invalid agent id: ${opts.agentId}`);
83
+ }
84
+ return {
85
+ FLAIR_BIN: flairBin,
86
+ SHIM_PATH: shimPath,
87
+ HOME: homedir(),
88
+ AGENT_ID: opts.agentId,
89
+ FLAIR_URL: opts.flairUrl,
90
+ HOUR: String(opts.hour),
91
+ HOUR_PAD: String(opts.hour).padStart(2, "0"),
92
+ MINUTE: String(opts.minute),
93
+ MINUTE_PAD: String(opts.minute).padStart(2, "0"),
94
+ };
95
+ }
96
+ function writeFileWithDir(path, contents, mode = 0o600) {
97
+ const dir = dirname(path);
98
+ if (!existsSync(dir))
99
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
100
+ writeFileSync(path, contents, { mode });
101
+ }
102
+ // 30s ceiling on launchctl/systemctl invocations so a hung service manager
103
+ // can't block the CLI indefinitely. Sherlock #415 follow-up.
104
+ const SPAWN_TIMEOUT_MS = 30_000;
105
+ function spawnReport(cmd) {
106
+ const r = spawnSync(cmd[0], cmd.slice(1), {
107
+ encoding: "buffer",
108
+ timeout: SPAWN_TIMEOUT_MS,
109
+ });
110
+ return {
111
+ code: r.status,
112
+ stdout: r.stdout?.toString("utf-8") ?? "",
113
+ stderr: r.stderr?.toString("utf-8") ?? "",
114
+ };
115
+ }
116
+ /**
117
+ * Installs the platform-native scheduler entry and the shim script.
118
+ *
119
+ * macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist + bootstraps it via launchctl.
120
+ * Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} + enables the timer.
121
+ *
122
+ * In both cases, also deploys ~/.flair/bin/flair-rem-nightly as the shim
123
+ * the scheduler invokes.
124
+ */
125
+ export function enableScheduler(opts) {
126
+ const plat = detectPlatform(opts.platformOverride);
127
+ const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
128
+ const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
129
+ const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
130
+ const subs = buildSubstitutions(opts, shimPath, flairBin);
131
+ // 1. Deploy the shim (always — both platforms invoke it).
132
+ const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-rem-nightly.sh.tmpl"), subs);
133
+ writeFileWithDir(shimPath, shimContents, 0o700);
134
+ chmodSync(shimPath, 0o700);
135
+ // 2. Write the scheduler entry.
136
+ if (plat === "darwin") {
137
+ const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
138
+ const plistContents = renderTemplate(readTemplate(templateRoot, "launchd/dev.flair.rem.nightly.plist.tmpl"), subs);
139
+ writeFileWithDir(plistPath, plistContents, 0o600);
140
+ const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
141
+ let loadResult;
142
+ if (!opts.skipLoad) {
143
+ // Bootout first in case a prior install left the job loaded.
144
+ spawnReport(["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath]);
145
+ loadResult = spawnReport(loadCommand);
146
+ }
147
+ return { platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult };
148
+ }
149
+ // Linux: systemd user units.
150
+ const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
151
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
152
+ const serviceContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.service.tmpl"), subs);
153
+ const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
154
+ writeFileWithDir(servicePath, serviceContents, 0o600);
155
+ writeFileWithDir(timerPath, timerContents, 0o600);
156
+ const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
157
+ let loadResult;
158
+ if (!opts.skipLoad) {
159
+ spawnReport(["systemctl", "--user", "daemon-reload"]);
160
+ loadResult = spawnReport(loadCommand);
161
+ }
162
+ return { platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult };
163
+ }
164
+ /**
165
+ * Removes the scheduler entry. Audit log + snapshots are preserved.
166
+ */
167
+ export function disableScheduler(opts = {}) {
168
+ const plat = detectPlatform(opts.platformOverride);
169
+ const removed = [];
170
+ if (plat === "darwin") {
171
+ const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
172
+ const unloadCommand = ["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath];
173
+ let unloadResult;
174
+ if (existsSync(plistPath)) {
175
+ if (!opts.skipUnload) {
176
+ unloadResult = spawnReport(unloadCommand);
177
+ }
178
+ rmSync(plistPath, { force: true });
179
+ removed.push(plistPath);
180
+ }
181
+ if (opts.removeShim) {
182
+ const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
183
+ if (existsSync(shim)) {
184
+ rmSync(shim, { force: true });
185
+ removed.push(shim);
186
+ }
187
+ }
188
+ return { platform: plat, removed, unloadCommand, unloadResult };
189
+ }
190
+ const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
191
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
192
+ const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
193
+ let unloadResult;
194
+ if (existsSync(timerPath) || existsSync(servicePath)) {
195
+ if (!opts.skipUnload) {
196
+ unloadResult = spawnReport(unloadCommand);
197
+ spawnReport(["systemctl", "--user", "daemon-reload"]);
198
+ }
199
+ if (existsSync(timerPath)) {
200
+ rmSync(timerPath, { force: true });
201
+ removed.push(timerPath);
202
+ }
203
+ if (existsSync(servicePath)) {
204
+ rmSync(servicePath, { force: true });
205
+ removed.push(servicePath);
206
+ }
207
+ }
208
+ if (opts.removeShim) {
209
+ const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
210
+ if (existsSync(shim)) {
211
+ rmSync(shim, { force: true });
212
+ removed.push(shim);
213
+ }
214
+ }
215
+ return { platform: plat, removed, unloadCommand, unloadResult };
216
+ }
217
+ /**
218
+ * Reports whether the scheduler is installed. Filesystem-only — does not
219
+ * shell out to launchctl/systemctl to query active state. The Health
220
+ * endpoint already does that via existence checks; same approach here.
221
+ */
222
+ export function schedulerStatus(opts = {}) {
223
+ const plat = detectPlatform(opts.platformOverride);
224
+ if (plat === "darwin") {
225
+ return {
226
+ platform: plat,
227
+ installed: existsSync(LAUNCHD_PLIST_PATH),
228
+ schedulerPath: LAUNCHD_PLIST_PATH,
229
+ shimPath: SHIM_PATH_DEFAULT,
230
+ shimExists: existsSync(SHIM_PATH_DEFAULT),
231
+ };
232
+ }
233
+ return {
234
+ platform: plat,
235
+ installed: existsSync(SYSTEMD_TIMER_PATH) && existsSync(SYSTEMD_SERVICE_PATH),
236
+ schedulerPath: SYSTEMD_TIMER_PATH,
237
+ shimPath: SHIM_PATH_DEFAULT,
238
+ shimExists: existsSync(SHIM_PATH_DEFAULT),
239
+ };
240
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * REM snapshot module — pre-cycle snapshot + listing + restore extraction.
3
+ *
4
+ * Per FLAIR-NIGHTLY-REM § 4 step 2 and § 9. Produces tar.gz archives at
5
+ * ~/.flair/snapshots/<agentId>/<ISO-timestamp>.tar.gz containing:
6
+ * - memories.jsonl — one Memory row per line
7
+ * - soul.json — Soul row (or null)
8
+ * - metadata.json — agent id, run id, flair version, counts
9
+ *
10
+ * Mirrors the `flair session snapshot` pattern (tar.gz, 600 perms, agent-
11
+ * rooted under ~/.flair/snapshots/). Pure filesystem — the caller fetches
12
+ * the data via the Harper HTTP API and passes it in.
13
+ *
14
+ * Used by:
15
+ * - `flair rem snapshot list`
16
+ * - `flair rem restore <date>`
17
+ * - The nightly runner (slice-1 follow-on)
18
+ */
19
+ import { mkdirSync, writeFileSync, statSync, chmodSync, rmSync, existsSync, readdirSync } from "node:fs";
20
+ import { resolve } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
23
+ export const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
24
+ /** Validates the agent id and returns its snapshot directory. */
25
+ export function remSnapshotDir(agent) {
26
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent)) {
27
+ throw new Error(`invalid agent id: ${agent}`);
28
+ }
29
+ return resolve(SNAPSHOT_ROOT, agent);
30
+ }
31
+ /**
32
+ * Creates a tar.gz snapshot at <root>/<agentId>/<ISO-timestamp>.tar.gz.
33
+ * Returns the path, byte size, and metadata embedded in the archive.
34
+ *
35
+ * Tarball perms: 0600 (owner-only).
36
+ */
37
+ export async function createSnapshot(opts) {
38
+ const now = opts.nowOverride ?? new Date();
39
+ const isoFull = now.toISOString().replace(/[:.]/g, "-");
40
+ const root = opts.rootOverride ?? SNAPSHOT_ROOT;
41
+ if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
42
+ throw new Error(`invalid agent id: ${opts.agentId}`);
43
+ }
44
+ const dir = resolve(root, opts.agentId);
45
+ if (!existsSync(dir))
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ const tarballName = `${isoFull}.tar.gz`;
48
+ const tarballPath = resolve(dir, tarballName);
49
+ const meta = {
50
+ agentId: opts.agentId,
51
+ runId: opts.runId ?? `rem-nightly-${isoFull}`,
52
+ flairVersion: opts.flairVersion,
53
+ createdAt: now.toISOString(),
54
+ memoryCount: opts.memories.length,
55
+ pendingCandidateCount: opts.pendingCandidateCount,
56
+ soulPresent: opts.soul !== null,
57
+ };
58
+ const tmpDir = resolve(dir, `.tmp-${process.pid}-${Date.now()}`);
59
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
60
+ try {
61
+ const memoryLines = opts.memories.map((m) => JSON.stringify(m)).join("\n");
62
+ writeFileSync(resolve(tmpDir, "memories.jsonl"), memoryLines + (memoryLines.length ? "\n" : ""), { mode: 0o600 });
63
+ writeFileSync(resolve(tmpDir, "soul.json"), JSON.stringify(opts.soul, null, 2) + "\n", { mode: 0o600 });
64
+ writeFileSync(resolve(tmpDir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 });
65
+ await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, ["memories.jsonl", "soul.json", "metadata.json"]);
66
+ chmodSync(tarballPath, 0o600);
67
+ }
68
+ finally {
69
+ rmSync(tmpDir, { recursive: true, force: true });
70
+ }
71
+ return { path: tarballPath, size: statSync(tarballPath).size, meta };
72
+ }
73
+ /**
74
+ * Lists snapshots at <root>/<agent>/*.tar.gz. Returns rows sorted by mtime
75
+ * descending. Pass an agent filter to restrict to a single agent.
76
+ */
77
+ export function listSnapshots(agentFilter, rootOverride) {
78
+ const root = rootOverride ?? SNAPSHOT_ROOT;
79
+ if (!existsSync(root))
80
+ return [];
81
+ let agents;
82
+ if (agentFilter) {
83
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentFilter)) {
84
+ throw new Error(`invalid agent id: ${agentFilter}`);
85
+ }
86
+ agents = [agentFilter];
87
+ }
88
+ else {
89
+ agents = readdirSync(root).filter((d) => {
90
+ try {
91
+ return statSync(resolve(root, d)).isDirectory();
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ });
97
+ }
98
+ const rows = [];
99
+ for (const a of agents) {
100
+ const dir = resolve(root, a);
101
+ if (!existsSync(dir))
102
+ continue;
103
+ for (const f of readdirSync(dir)) {
104
+ if (!f.endsWith(".tar.gz"))
105
+ continue;
106
+ const p = resolve(dir, f);
107
+ const s = statSync(p);
108
+ rows.push({ agent: a, file: f, path: p, size: s.size, mtime: s.mtime.toISOString() });
109
+ }
110
+ }
111
+ rows.sort((a, b) => b.mtime.localeCompare(a.mtime));
112
+ return rows;
113
+ }
114
+ /**
115
+ * Extracts a snapshot tar.gz into a target directory. Refuses to extract
116
+ * over an existing directory — operator must pass a clean target.
117
+ *
118
+ * In dry-run mode, returns the tarball entry list without writing anything.
119
+ */
120
+ export async function extractSnapshot(opts) {
121
+ if (!existsSync(opts.snapshotPath)) {
122
+ throw new Error(`snapshot does not exist: ${opts.snapshotPath}`);
123
+ }
124
+ const entries = [];
125
+ await tarList({
126
+ file: opts.snapshotPath,
127
+ onReadEntry: (e) => entries.push({ path: e.path, size: e.size ?? 0 }),
128
+ });
129
+ if (opts.dryRun) {
130
+ return { entries };
131
+ }
132
+ const targetDir = opts.targetDir ?? `${opts.snapshotPath}.restored`;
133
+ if (existsSync(targetDir)) {
134
+ throw new Error(`target directory already exists: ${targetDir}`);
135
+ }
136
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
137
+ await tarExtract({ file: opts.snapshotPath, cwd: targetDir });
138
+ return { targetDir, entries };
139
+ }
package/dist/render.js ADDED
@@ -0,0 +1,168 @@
1
+ // CLI output renderer — color, tables, icons, spinner, output-mode resolution.
2
+ //
3
+ // Two output modes coexist across every command:
4
+ //
5
+ // human — pretty default, ANSI color when stdout is a TTY and NO_COLOR/
6
+ // FLAIR_NO_COLOR aren't set
7
+ // json — agent-default, also auto-selected when stdout is piped
8
+ //
9
+ // Precedence: --json flag > FLAIR_OUTPUT=json env > non-TTY stdout > TTY (human).
10
+ // Setting FLAIR_OUTPUT=human forces human mode even when piped.
11
+ //
12
+ // No third-party deps — minimal ANSI by hand keeps the dependency tree flat
13
+ // (the rest of the flair runtime already runs ANSI-free; we don't need
14
+ // chalk/picocolors' edge-case coverage for SGR codes we don't use).
15
+ const stdoutIsTTY = !!process.stdout.isTTY;
16
+ const stderrIsTTY = !!process.stderr.isTTY;
17
+ const noColorEnv = process.env.NO_COLOR != null || process.env.FLAIR_NO_COLOR != null;
18
+ const enableColor = stdoutIsTTY && !noColorEnv;
19
+ const C = (code) => (enableColor ? `\x1b[${code}m` : "");
20
+ export const c = {
21
+ reset: C("0"),
22
+ bold: C("1"),
23
+ dim: C("2"),
24
+ italic: C("3"),
25
+ underline: C("4"),
26
+ red: C("31"),
27
+ green: C("32"),
28
+ yellow: C("33"),
29
+ blue: C("34"),
30
+ magenta: C("35"),
31
+ cyan: C("36"),
32
+ white: C("37"),
33
+ gray: C("90"),
34
+ };
35
+ export function wrap(color, text) {
36
+ if (!color)
37
+ return text;
38
+ return `${color}${text}${c.reset}`;
39
+ }
40
+ export function resolveOutputMode(opts) {
41
+ if (opts.json)
42
+ return "json";
43
+ const envOut = process.env.FLAIR_OUTPUT;
44
+ if (envOut === "json")
45
+ return "json";
46
+ if (envOut === "human")
47
+ return "human";
48
+ // No explicit selection — pipe-friendly default: non-TTY stdout → json.
49
+ return stdoutIsTTY ? "human" : "json";
50
+ }
51
+ export const icons = {
52
+ ok: wrap(c.green, "✓"),
53
+ warn: wrap(c.yellow, "⚠"),
54
+ error: wrap(c.red, "✗"),
55
+ info: wrap(c.cyan, "ℹ"),
56
+ bullet: wrap(c.gray, "·"),
57
+ pending: wrap(c.gray, "○"),
58
+ arrow: wrap(c.gray, "→"),
59
+ };
60
+ // Status header: bullet + bold label. Use for top-level command titles.
61
+ export function header(label) {
62
+ return wrap(c.bold, label);
63
+ }
64
+ // Section divider — used between groups inside one command's output.
65
+ // Subtle line + bold label, no big ═══ banners (those compete with content).
66
+ export function section(label) {
67
+ return `\n${wrap(c.bold, label)}\n${wrap(c.gray, "─".repeat(Math.min(60, label.length + 8)))}`;
68
+ }
69
+ // Key/value pair with aligned label. Default label width 14 cols.
70
+ export function kv(label, value, labelWidth = 14) {
71
+ return ` ${wrap(c.dim, label.padEnd(labelWidth))} ${value}`;
72
+ }
73
+ export function table(columns, rows) {
74
+ if (rows.length === 0)
75
+ return wrap(c.dim, " (no rows)");
76
+ const widths = columns.map((col) => col.label.length);
77
+ const cells = rows.map((row) => columns.map((col, i) => {
78
+ const raw = row[col.key];
79
+ const formatted = col.format ? col.format(raw, row) : raw == null ? "—" : String(raw);
80
+ // Visible-width calculation — strip our own ANSI escapes before counting
81
+ const visibleLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
82
+ if (visibleLen > widths[i])
83
+ widths[i] = visibleLen;
84
+ return formatted;
85
+ }));
86
+ const align = (str, width, side) => {
87
+ const visibleLen = str.replace(/\x1b\[[0-9;]*m/g, "").length;
88
+ const pad = " ".repeat(Math.max(0, width - visibleLen));
89
+ return side === "right" ? pad + str : str + pad;
90
+ };
91
+ const headerRow = " " +
92
+ columns.map((col, i) => wrap(c.dim, align(col.label, widths[i], col.align))).join(" ");
93
+ const bodyRows = cells
94
+ .map((row) => " " + row.map((cell, i) => align(cell, widths[i], columns[i].align)).join(" "))
95
+ .join("\n");
96
+ return `${headerRow}\n${bodyRows}`;
97
+ }
98
+ export function spinner(label) {
99
+ if (!stderrIsTTY || noColorEnv) {
100
+ process.stderr.write(`${label}...\n`);
101
+ return {
102
+ stop: (final) => {
103
+ if (final)
104
+ process.stderr.write(`${final}\n`);
105
+ },
106
+ update: (next) => {
107
+ process.stderr.write(`${next}...\n`);
108
+ },
109
+ };
110
+ }
111
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
112
+ let current = label;
113
+ let i = 0;
114
+ const handle = setInterval(() => {
115
+ process.stderr.write(`\r${wrap(c.cyan, frames[i])} ${current}`);
116
+ i = (i + 1) % frames.length;
117
+ }, 80);
118
+ return {
119
+ stop: (final) => {
120
+ clearInterval(handle);
121
+ process.stderr.write("\r\x1b[K");
122
+ if (final)
123
+ process.stderr.write(`${final}\n`);
124
+ },
125
+ update: (next) => {
126
+ current = next;
127
+ },
128
+ };
129
+ }
130
+ // Canonical JSON output: 2-space indent, trailing newline omitted by caller.
131
+ export function asJSON(value) {
132
+ return JSON.stringify(value, null, 2);
133
+ }
134
+ // Bytes → human readable. Mirror of humanBytes in cli.ts; centralizing here
135
+ // so render-aware code shares the same format.
136
+ export function humanBytes(n) {
137
+ if (!Number.isFinite(n) || n < 0)
138
+ return "—";
139
+ if (n < 1024)
140
+ return `${n} B`;
141
+ if (n < 1024 * 1024)
142
+ return `${(n / 1024).toFixed(1)} KB`;
143
+ if (n < 1024 * 1024 * 1024)
144
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
145
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
146
+ }
147
+ // ISO timestamp → "12m ago" / "2h ago" / "5d ago" / "—" (null) / fallback.
148
+ export function relativeTime(iso) {
149
+ if (!iso)
150
+ return "—";
151
+ const ago = Date.now() - new Date(iso).getTime();
152
+ if (!Number.isFinite(ago) || ago < 0)
153
+ return "—";
154
+ const mins = Math.floor(ago / 60000);
155
+ const hrs = Math.floor(ago / 3600000);
156
+ const days = Math.floor(ago / 86400000);
157
+ return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
158
+ }
159
+ // Convenience: print the right shape for the resolved output mode.
160
+ // Caller passes both representations and the resolveOutputMode result.
161
+ export function print(mode, jsonValue, humanText) {
162
+ if (mode === "json") {
163
+ console.log(asJSON(jsonValue));
164
+ }
165
+ else {
166
+ console.log(humanText);
167
+ }
168
+ }
@@ -265,6 +265,17 @@ function statusFromOrgEvent(event) {
265
265
  null);
266
266
  }
267
267
  export class A2AAdapter extends Resource {
268
+ // A2A discovery surface — agent-card metadata (GET) is intentionally
269
+ // public so other agents/clients can discover this Flair as a memory
270
+ // peer. POST is JSON-RPC for actions (message/send writes OrgEvents,
271
+ // tasks/list reads Beads issues, message/stream subscribes to events)
272
+ // and is auth-gated upstream by auth-middleware's narrowed allow-list
273
+ // (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
274
+ // Basic). The post() handler below additionally enforces sender ==
275
+ // params.agentId for message/send so an authenticated caller can only
276
+ // act AS themselves, not impersonate another agent.
277
+ allowRead() { return true; }
278
+ allowCreate() { return true; }
268
279
  async get() {
269
280
  const host = process.env.FLAIR_PUBLIC_URL || "http://localhost:9926";
270
281
  return new Response(JSON.stringify({
@@ -305,6 +316,20 @@ export class A2AAdapter extends Resource {
305
316
  }
306
317
  const id = body.id ?? null;
307
318
  const params = body.params ?? {};
319
+ // Defense-in-depth: auth-middleware narrows the /a2a allow-list so
320
+ // POSTs only reach here after TPS-Ed25519 or admin Basic succeeds,
321
+ // which sets request.tpsAgent / tpsAgentIsAdmin. If middleware was
322
+ // misconfigured or someone added a back-door allow-list entry, fail
323
+ // closed here. Pattern matches WorkspaceState/Memory/etc.
324
+ const ctx = this.getContext?.() ?? _context ?? {};
325
+ const ctxRequest = ctx.request ?? ctx;
326
+ const callerAgent = ctxRequest?.tpsAgent;
327
+ const callerIsAdmin = ctxRequest?.tpsAgentIsAdmin === true;
328
+ if (!callerAgent && !callerIsAdmin) {
329
+ return rpcError(id, -32001, "Unauthorized", {
330
+ detail: "POST /a2a requires TPS-Ed25519 or admin Basic auth",
331
+ });
332
+ }
308
333
  try {
309
334
  if (body.method === "message/stream") {
310
335
  const agentId = cleanText(params.agentId);
@@ -402,6 +427,16 @@ export class A2AAdapter extends Resource {
402
427
  if (!agentId || !message || typeof message !== "object") {
403
428
  return rpcError(id, -32602, "Invalid params: agentId and message are required");
404
429
  }
430
+ // Sender must match params.agentId — you can only send AS yourself.
431
+ // Admin agents may send as anyone (operational convenience).
432
+ // Without this check, an authenticated agent could forge OrgEvents
433
+ // attributed to any other agent — defeats the whole signed-envelopes
434
+ // model that delegationChain enforces for TPS mail.
435
+ if (!callerIsAdmin && callerAgent !== agentId) {
436
+ return rpcError(id, -32001, "Forbidden", {
437
+ detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
438
+ });
439
+ }
405
440
  const agent = await databases.flair.Agent.get(agentId).catch(() => null);
406
441
  if (!agent) {
407
442
  return rpcError(id, -32004, "Agent not found", { agentId });
@@ -0,0 +1,16 @@
1
+ import { Resource } from "@harperfast/harper";
2
+ /**
3
+ * GET /Admin — friendly redirect to /AdminDashboard.
4
+ *
5
+ * Operators bookmark or type the bare /Admin path. Without this resource
6
+ * they hit a 404 and assume the admin UI is broken. The dashboard is the
7
+ * canonical landing surface; redirect there.
8
+ */
9
+ export class Admin extends Resource {
10
+ async get() {
11
+ return new Response("", {
12
+ status: 302,
13
+ headers: { Location: "/AdminDashboard" },
14
+ });
15
+ }
16
+ }