This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

pty tag-multi: multi-session tag operations + harden bulk pty tag

New sibling command pty tag-multi for multi-session work. Single-
session pty tag stays as-is; pty tag-multi lives next to it so a
fingerslip cant accidentally rewrite the wrong scope.

Three mutually-exclusive selectors:
<name>... explicit list (resolved up-front; if any
name is unresolvable the command aborts
before any write — no partial application)
--filter-tag k=v sessions matching tag(s); repeatable AND
--all every session

No ops = read mode (per-session tag dump, text or --json returning
{ name → tags }). Any ops (k=v / --rm k) flips to write mode. --all
writes are destructive across the whole session dir, so they require
--yes / -y; without it the command prints the matched count and
exits non-zero, no writes performed. Empty match (filter or --all
over an empty dir) is exit 0 with "0 sessions matched."

Each successful write goes through the existing atomic updateTags
+ tags_change event flow per-session; per-session no-ops emit
nothing. Selector resolution is upfront for explicit lists, lazy
for filter / --all (just listSessions).

Also hardens the existing single-session pty tag while we're here:
- rejects empty key in `=value`
- rejects --rm "" (empty key for removal)
- clearer error for --rm at end of argv with no key
- tightened parsing comments

Tests:
tests/tag-multi.test.ts (38 tests) — read explicit list, read
selectors (--filter-tag single/repeated/empty, --all on empty
dir), write explicit list (events fire per session, no-op on
one peer doesnt suppress the others, upfront-unresolvable
aborts), write selectors (--filter-tag, --all without --yes
rejected, --all --yes applied, -y short form), selector mutex
(--all+filter, --all+names, filter+names, no selector, ops
without selector), ops parsing errors (empty key, --rm at end,
--rm "", --filter-tag without value, --filter-tag without =,
multi-= splits on first =, set+rm same key wins for rm to
match pty tag).

tests/tag-bulk.test.ts (29 tests) — pin the existing pty tag
contract: bulk set, bulk rm, combined set+rm, position
independence, empty key/value edges, atomic events (one
tags_change per call), no-op detection, partial no-op + real
change, displayName resolution.

CHANGELOG and README reflect both.

Nathan Herald (Apr 25, 2026, 11:53 PM +0200) 8435c9bd 51a3bbb3

+1316 -10
+8
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Tags 6 + - **`pty tag-multi` — multi-session tag operations.** New sibling command for read and write across many sessions in one CLI invocation. Three mutually-exclusive selectors: 7 + - `<name>...` — explicit list (resolved up-front; if any name is unresolvable the command aborts before any write so partial application doesn't happen) 8 + - `--filter-tag k=v` — sessions matching tag(s); repeatable for AND 9 + - `--all` — every session 10 + No ops = read mode (per-session tag dump, text or `--json` returning `{ name → tags }`). Any ops (`k=v` / `--rm k`) flips to write mode. `--all` writes are destructive across the whole session dir, so they require `--yes` / `-y`; without it the command exits non-zero with the matched count and no writes. Empty match (filter or `--all` over an empty dir) is exit 0 with `0 sessions matched.`. Each successful write is individually atomic and emits its own `tags_change` event; per-session no-ops (effective tags unchanged) emit nothing. The single-session `pty tag` command is unchanged — `pty tag-multi` is a separate entry point so a fingerslip can't accidentally rewrite the wrong scope. Examples: `pty tag-multi --all --json`, `pty tag-multi --filter-tag role=web env=prod`, `pty tag-multi sess-a sess-b --rm temp-flag`, `pty tag-multi --all --yes audit=2026-04-25`. 11 + - **`pty tag` now has a defined contract for bulk operations.** Multiple `key=value` and `--rm <key>` may appear in any order in a single invocation; updates apply before removals (so `pty tag X k=v --rm k` ends with `k` removed, by design — last-step-wins). All tag mutations fan into one atomic `writeMetadata` and one `tags_change` event with full before/after snapshots; effective no-ops (setting current values, removing missing keys, set+rm of a key that never existed) emit nothing. Same-key duplicates: last positional wins for sets, idempotent for `--rm`. New error surface: empty keys (`=value`, `--rm ""`) and `--rm` at end-without-arg are rejected with clear messages. Bulk pattern is the recommended shape for hooks that stamp several tags at once — pays Node startup once instead of N times. The single-key form (`pty tag X role=web`) is unchanged. 12 + 5 13 ### Storage format 6 14 - **Documented the on-disk layout** in [docs/disk-layout.md](docs/disk-layout.md). Covers `PTY_SESSION_DIR`, the full directory contents (`<name>.json`, `.events.jsonl`, `.sock`, `.pid`, `.lock`, `theme`, `supervisor/`), the metadata JSON shape field-by-field, the events JSONL line format and every built-in event type, the atomic-write tmp-file convention (`<target>.tmp.<pid>.<rand>` — third-party readers must ignore these), and stability tiers (tier 1 = "we'll try not to break this," tier 2 = "may move freely"). Aimed at non-Node consumers who want to skip the CLI's startup cost — read the JSON directly or ship a fast-path reader as a `pty-<subcommand>` binary on `$PATH`. Pre-1.0 caveat: schema may change in any release; pin to a pty version. The README's Events section now points at the doc. 7 15 - **Drift-guard test** (`tests/disk-layout-docs.test.ts`) asserts every `SessionMetadata` field name, every concrete event-type literal, and every file extension we write under `PTY_SESSION_DIR` is mentioned in the doc. Adding a field or event type without documenting it fails CI. The corresponding interfaces in `src/sessions.ts` and `src/events.ts` carry `// PUBLIC FORMAT` comments pointing at the doc.
+5 -2
README.md
··· 110 110 pty kill myserver # terminate a running session 111 111 pty rm myserver # remove an exited session's metadata 112 112 pty gc # remove all exited sessions 113 - pty tag myserver role=web # set tags on a session 114 - pty tag myserver --rm role # remove a tag 113 + pty tag myserver role=web env=prod # set one or more tags on a session 114 + pty tag myserver --rm role --rm env # remove one or more tags 115 + pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions 116 + pty tag-multi --all --json # bulk read tags across every session 117 + pty tag-multi --all --yes audit=today # write to every session (--yes required) 115 118 116 119 pty supervisor start # start the session supervisor 117 120 pty supervisor stop # stop the supervisor
+271 -8
src/cli.ts
··· 737 737 } 738 738 const resolvedTagName = await resolveRef(tagName); 739 739 740 + // Bulk-friendly parsing. Multiple `key=value` and `--rm <key>` may 741 + // appear in any order; updates apply before removals (see updateTags 742 + // in sessions.ts), so `pty tag X k=v --rm k` ends with k removed. 740 743 const updates: Record<string, string> = {}; 741 744 const removals: string[] = []; 742 745 for (let i = 2; i < args.length; i++) { 743 - if (args[i] === "--rm" && i + 1 < args.length) { 744 - removals.push(args[i + 1]); 745 - i++; 746 - } else { 747 - const eq = args[i].indexOf("="); 748 - if (eq === -1) { 749 - console.error(`Invalid tag format: "${args[i]}". Use key=value or --rm key`); 746 + if (args[i] === "--rm") { 747 + if (i + 1 >= args.length) { 748 + console.error("pty tag: --rm requires a key (e.g. --rm role)"); 749 + process.exit(1); 750 + } 751 + const rmKey = args[i + 1]; 752 + if (rmKey === "") { 753 + console.error("pty tag: --rm requires a non-empty key"); 750 754 process.exit(1); 751 755 } 752 - updates[args[i].slice(0, eq)] = args[i].slice(eq + 1); 756 + removals.push(rmKey); 757 + i++; 758 + continue; 753 759 } 760 + const eq = args[i].indexOf("="); 761 + if (eq === -1) { 762 + console.error(`pty tag: invalid argument "${args[i]}". Use key=value or --rm key.`); 763 + process.exit(1); 764 + } 765 + const key = args[i].slice(0, eq); 766 + if (key === "") { 767 + console.error(`pty tag: empty key in "${args[i]}". Tag keys must be non-empty.`); 768 + process.exit(1); 769 + } 770 + updates[key] = args[i].slice(eq + 1); 754 771 } 755 772 756 773 // No updates or removals — show current tags ··· 795 812 console.error(e.message); 796 813 process.exit(1); 797 814 } 815 + break; 816 + } 817 + 818 + case "tag-multi": { 819 + await cmdTagMulti(args.slice(1)); 798 820 break; 799 821 } 800 822 ··· 1996 2018 ? `Would clean up ${parts.join(" and ")}. (Dry run — no changes made.)` 1997 2019 : `Cleaned up ${parts.join(" and ")}.`, 1998 2020 ); 2021 + } 2022 + 2023 + // --- tag-multi: read/write tags across multiple sessions in one call --- 2024 + // 2025 + // Three selectors (mutually exclusive): 2026 + // <name>... explicit list (resolved up-front; any unresolvable 2027 + // name aborts before any write) 2028 + // --filter-tag k=v ... sessions whose tags match all listed pairs 2029 + // --all every session 2030 + // 2031 + // No ops = read mode (per-session tag dump, text or --json). Any ops 2032 + // (`k=v` / `--rm k`) = write mode; --all + write mode requires --yes 2033 + // because it operates on every session in the dir. 2034 + 2035 + interface TagMultiParsed { 2036 + selector: 2037 + | { kind: "names"; names: string[] } 2038 + | { kind: "filter"; filterTags: Record<string, string> } 2039 + | { kind: "all" }; 2040 + ops: { updates: Record<string, string>; removals: string[] }; 2041 + json: boolean; 2042 + yes: boolean; 2043 + } 2044 + 2045 + function parseTagMultiArgs(argv: string[]): TagMultiParsed { 2046 + let all = false; 2047 + const filterTags: Record<string, string> = {}; 2048 + const names: string[] = []; 2049 + const updates: Record<string, string> = {}; 2050 + const removals: string[] = []; 2051 + let json = false; 2052 + let yes = false; 2053 + 2054 + for (let i = 0; i < argv.length; i++) { 2055 + const a = argv[i]; 2056 + if (a === "--all") { all = true; continue; } 2057 + if (a === "--json") { json = true; continue; } 2058 + if (a === "--yes" || a === "-y") { yes = true; continue; } 2059 + if (a === "-h" || a === "--help") { 2060 + printTagMultiHelp(); 2061 + process.exit(0); 2062 + } 2063 + if (a === "--filter-tag") { 2064 + if (i + 1 >= argv.length) { 2065 + console.error("pty tag-multi: --filter-tag requires k=v"); 2066 + process.exit(1); 2067 + } 2068 + const next = argv[++i]; 2069 + const eq = next.indexOf("="); 2070 + if (eq === -1) { 2071 + console.error(`pty tag-multi: --filter-tag value "${next}" must be k=v`); 2072 + process.exit(1); 2073 + } 2074 + const k = next.slice(0, eq); 2075 + if (k === "") { 2076 + console.error("pty tag-multi: --filter-tag key must be non-empty"); 2077 + process.exit(1); 2078 + } 2079 + filterTags[k] = next.slice(eq + 1); 2080 + continue; 2081 + } 2082 + if (a === "--rm") { 2083 + if (i + 1 >= argv.length) { 2084 + console.error("pty tag-multi: --rm requires a key (e.g. --rm role)"); 2085 + process.exit(1); 2086 + } 2087 + const k = argv[++i]; 2088 + if (k === "") { 2089 + console.error("pty tag-multi: --rm requires a non-empty key"); 2090 + process.exit(1); 2091 + } 2092 + removals.push(k); 2093 + continue; 2094 + } 2095 + const eq = a.indexOf("="); 2096 + if (eq !== -1) { 2097 + const k = a.slice(0, eq); 2098 + if (k === "") { 2099 + console.error(`pty tag-multi: empty key in "${a}". Tag keys must be non-empty.`); 2100 + process.exit(1); 2101 + } 2102 + updates[k] = a.slice(eq + 1); 2103 + continue; 2104 + } 2105 + // Anything else is a positional session name. validateName will catch 2106 + // illegal characters at resolution time. 2107 + names.push(a); 2108 + } 2109 + 2110 + // Selector mutex check. 2111 + const selectorCount = 2112 + (all ? 1 : 0) + 2113 + (Object.keys(filterTags).length > 0 ? 1 : 0) + 2114 + (names.length > 0 ? 1 : 0); 2115 + if (selectorCount === 0) { 2116 + console.error( 2117 + "pty tag-multi: no selector — pass session names, --filter-tag k=v, or --all", 2118 + ); 2119 + process.exit(1); 2120 + } 2121 + if (selectorCount > 1) { 2122 + console.error( 2123 + "pty tag-multi: selectors are mutually exclusive — pick one of <names>, --filter-tag, --all", 2124 + ); 2125 + process.exit(1); 2126 + } 2127 + 2128 + let selector: TagMultiParsed["selector"]; 2129 + if (all) selector = { kind: "all" }; 2130 + else if (Object.keys(filterTags).length > 0) selector = { kind: "filter", filterTags }; 2131 + else selector = { kind: "names", names }; 2132 + 2133 + return { selector, ops: { updates, removals }, json, yes }; 2134 + } 2135 + 2136 + async function cmdTagMulti(argv: string[]): Promise<void> { 2137 + const parsed = parseTagMultiArgs(argv); 2138 + const isWrite = 2139 + Object.keys(parsed.ops.updates).length > 0 || parsed.ops.removals.length > 0; 2140 + 2141 + // Resolve selector → list of stable session ids. Explicit names are 2142 + // resolved up-front so an unresolvable name aborts before any writes. 2143 + let targets: string[]; 2144 + if (parsed.selector.kind === "names") { 2145 + targets = []; 2146 + for (const ref of parsed.selector.names) { 2147 + try { 2148 + validateName(ref); 2149 + } catch (e: any) { 2150 + console.error(`pty tag-multi: ${e.message}`); 2151 + process.exit(1); 2152 + } 2153 + const sess = await getSession(ref); 2154 + if (!sess) { 2155 + console.error(`pty tag-multi: session "${ref}" not found.`); 2156 + process.exit(1); 2157 + } 2158 + targets.push(sess.name); 2159 + } 2160 + } else if (parsed.selector.kind === "filter") { 2161 + const all = await listSessions(); 2162 + targets = all 2163 + .filter((s) => matchesAllTags(s.metadata?.tags, parsed.selector.kind === "filter" ? parsed.selector.filterTags : {})) 2164 + .map((s) => s.name); 2165 + } else { 2166 + // --all 2167 + if (isWrite && !parsed.yes) { 2168 + const all = await listSessions(); 2169 + console.error( 2170 + `pty tag-multi: --all writes are destructive across ${all.length} session(s). Re-run with --yes to apply.`, 2171 + ); 2172 + process.exit(1); 2173 + } 2174 + const all = await listSessions(); 2175 + targets = all.map((s) => s.name); 2176 + } 2177 + 2178 + // Read mode: collect per-session tags into an object. 2179 + if (!isWrite) { 2180 + const out: Record<string, Record<string, string>> = {}; 2181 + for (const name of targets) { 2182 + const meta = readMetadata(name); 2183 + out[name] = meta?.tags ?? {}; 2184 + } 2185 + if (parsed.json) { 2186 + console.log(JSON.stringify(out)); 2187 + return; 2188 + } 2189 + if (targets.length === 0) { 2190 + console.log("0 sessions matched."); 2191 + return; 2192 + } 2193 + for (const name of targets) { 2194 + const tags = out[name]; 2195 + const keys = Object.keys(tags); 2196 + if (keys.length === 0) { 2197 + console.log(`${name}: (no tags)`); 2198 + continue; 2199 + } 2200 + console.log(`${name}:`); 2201 + for (const k of keys) console.log(` ${k}=${tags[k]}`); 2202 + } 2203 + return; 2204 + } 2205 + 2206 + // Write mode: apply per-session, continue on error, exit 1 if any failed. 2207 + if (targets.length === 0) { 2208 + if (parsed.json) { 2209 + console.log(JSON.stringify({})); 2210 + } else { 2211 + console.log("0 sessions matched. No writes performed."); 2212 + } 2213 + return; 2214 + } 2215 + const results: Record<string, Record<string, string>> = {}; 2216 + const errors: { name: string; message: string }[] = []; 2217 + for (const name of targets) { 2218 + try { 2219 + updateTags(name, parsed.ops.updates, parsed.ops.removals); 2220 + const meta = readMetadata(name); 2221 + results[name] = meta?.tags ?? {}; 2222 + } catch (e: any) { 2223 + errors.push({ name, message: e.message ?? String(e) }); 2224 + } 2225 + } 2226 + if (parsed.json) { 2227 + console.log(JSON.stringify(results)); 2228 + } else { 2229 + const changed = Object.keys(results).length; 2230 + console.log(`${changed} session(s) processed.`); 2231 + } 2232 + if (errors.length > 0) { 2233 + for (const e of errors) { 2234 + console.error(`pty tag-multi: ${e.name}: ${e.message}`); 2235 + } 2236 + process.exit(1); 2237 + } 2238 + } 2239 + 2240 + function printTagMultiHelp(): void { 2241 + console.log(`Usage: 2242 + pty tag-multi <selector> [--json] [--yes] [<ops>...] 2243 + 2244 + Selectors (pick one): 2245 + <name>... explicit list of session names or displayNames 2246 + --filter-tag k=v sessions matching tag (repeatable for AND) 2247 + --all every session 2248 + 2249 + Operations (presence flips command into write mode): 2250 + k=v set tag k to v 2251 + --rm k remove tag k 2252 + 2253 + Flags: 2254 + --json structured output (object: name → tags) 2255 + --yes / -y required with --all when ops are present 2256 + 2257 + Examples: 2258 + pty tag-multi --all --json 2259 + pty tag-multi --filter-tag role=web env=prod 2260 + pty tag-multi sess-a sess-b --rm temp-flag 2261 + pty tag-multi --all --yes audit=2026-04-25`); 1999 2262 } 2000 2263 2001 2264 // --- emit: publish a user.* event to a session's events log ---
+445
tests/tag-bulk.test.ts
··· 1 + // Tests for the bulk-operation contract on `pty tag`. The single-set and 2 + // single-rm forms have been there since v0.x; this file pins down the 3 + // multi-key matrix (multiple sets, multiple --rm, set+rm in one call, 4 + // interleaved order, set/rm of the same key) and the error-surface 5 + // (missing =, empty key, --rm without arg). 6 + // 7 + // Contract (kept in sync with src/sessions.ts updateTags): 8 + // 1. Updates are applied before removals. So `pty tag X k=v --rm k` 9 + // sets k then removes it → final state is removed. 10 + // 2. Same-key duplicates within a single call: last positional wins 11 + // for sets; --rm is idempotent. 12 + // 3. Bulk operations fan into ONE writeMetadata call → ONE 13 + // tags_change event with full before/after snapshots. 14 + // 4. No-op bulk operations (effective tags unchanged) fire no event. 15 + // 5. Empty key (`=value`) and `--rm` at end-without-arg are rejected 16 + // with a clear error. 17 + 18 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 19 + import * as fs from "node:fs"; 20 + import * as os from "node:os"; 21 + import * as path from "node:path"; 22 + import { fileURLToPath } from "node:url"; 23 + import { spawn, spawnSync } from "node:child_process"; 24 + 25 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 26 + const nodeBin = process.execPath; 27 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 28 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 29 + 30 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-tagbulk-")); 31 + afterAll(() => { 32 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 33 + }); 34 + 35 + let bgPids: number[] = []; 36 + let sessionDirs: string[] = []; 37 + 38 + function makeSessionDir(): string { 39 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 40 + sessionDirs.push(dir); 41 + return dir; 42 + } 43 + 44 + let nameCounter = 0; 45 + function uniqueName(): string { 46 + return `tb${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 47 + } 48 + 49 + async function startDaemon( 50 + sessionDir: string, 51 + name: string, 52 + initialTags?: Record<string, string>, 53 + displayName?: string, 54 + ): Promise<number> { 55 + const config = JSON.stringify({ 56 + name, command: "cat", args: [], displayCommand: "cat", 57 + cwd: os.tmpdir(), rows: 24, cols: 80, 58 + ...(initialTags ? { tags: initialTags } : {}), 59 + ...(displayName ? { displayName } : {}), 60 + }); 61 + const child = spawn(nodeBin, [serverModule], { 62 + detached: true, 63 + stdio: ["ignore", "ignore", "pipe"], 64 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 65 + }); 66 + let stderr = ""; 67 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 68 + let exitCode: number | null = null; 69 + child.on("exit", (code) => { exitCode = code; }); 70 + (child.stderr as any)?.unref?.(); 71 + child.unref(); 72 + const socketPath = path.join(sessionDir, `${name}.sock`); 73 + const start = Date.now(); 74 + while (Date.now() - start < 5000) { 75 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 76 + try { 77 + fs.statSync(socketPath); 78 + await new Promise((r) => setTimeout(r, 100)); 79 + bgPids.push(child.pid!); 80 + return child.pid!; 81 + } catch {} 82 + await new Promise((r) => setTimeout(r, 50)); 83 + } 84 + throw new Error("Timeout waiting for daemon"); 85 + } 86 + 87 + function runCli(sessionDir: string, ...args: string[]) { 88 + return spawnSync(nodeBin, [cliPath, ...args], { 89 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 90 + encoding: "utf-8", 91 + timeout: 10_000, 92 + }); 93 + } 94 + 95 + function readMeta(sessionDir: string, name: string) { 96 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 97 + } 98 + 99 + function readEvents(dir: string, name: string): any[] { 100 + try { 101 + const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 102 + return content.trimEnd().split("\n").filter(Boolean).map((l) => JSON.parse(l)); 103 + } catch { return []; } 104 + } 105 + 106 + afterEach(() => { 107 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 108 + bgPids = []; 109 + for (const dir of sessionDirs) { 110 + try { 111 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 112 + } catch {} 113 + } 114 + sessionDirs = []; 115 + }); 116 + 117 + describe("pty tag — bulk set", () => { 118 + it("sets a single key=value (back-compat)", async () => { 119 + const dir = makeSessionDir(); 120 + const name = uniqueName(); 121 + await startDaemon(dir, name); 122 + 123 + const r = runCli(dir, "tag", name, "role=web"); 124 + expect(r.status).toBe(0); 125 + expect(readMeta(dir, name).tags).toEqual({ role: "web" }); 126 + }, 15000); 127 + 128 + it("sets multiple keys in one call", async () => { 129 + const dir = makeSessionDir(); 130 + const name = uniqueName(); 131 + await startDaemon(dir, name); 132 + 133 + const r = runCli(dir, "tag", name, "role=web", "owner=forge", "env=dev"); 134 + expect(r.status).toBe(0); 135 + expect(readMeta(dir, name).tags).toEqual({ 136 + role: "web", owner: "forge", env: "dev", 137 + }); 138 + }, 15000); 139 + 140 + it("same key set twice in one call: last value wins", async () => { 141 + const dir = makeSessionDir(); 142 + const name = uniqueName(); 143 + await startDaemon(dir, name); 144 + 145 + const r = runCli(dir, "tag", name, "color=red", "color=blue"); 146 + expect(r.status).toBe(0); 147 + expect(readMeta(dir, name).tags.color).toBe("blue"); 148 + }, 15000); 149 + 150 + it("merges with existing tags rather than replacing", async () => { 151 + const dir = makeSessionDir(); 152 + const name = uniqueName(); 153 + await startDaemon(dir, name, { existing: "yes" }); 154 + 155 + runCli(dir, "tag", name, "fresh=1", "another=2"); 156 + expect(readMeta(dir, name).tags).toEqual({ 157 + existing: "yes", fresh: "1", another: "2", 158 + }); 159 + }, 15000); 160 + 161 + it("allows empty value (key=)", async () => { 162 + const dir = makeSessionDir(); 163 + const name = uniqueName(); 164 + await startDaemon(dir, name); 165 + 166 + const r = runCli(dir, "tag", name, "key="); 167 + expect(r.status).toBe(0); 168 + expect(readMeta(dir, name).tags).toEqual({ key: "" }); 169 + }, 15000); 170 + 171 + it("multi-= splits on the first equals (key='foo', value='bar=baz')", async () => { 172 + const dir = makeSessionDir(); 173 + const name = uniqueName(); 174 + await startDaemon(dir, name); 175 + 176 + runCli(dir, "tag", name, "foo=bar=baz"); 177 + expect(readMeta(dir, name).tags).toEqual({ foo: "bar=baz" }); 178 + }, 15000); 179 + 180 + it("supports many tags in a single call (atomicity sanity)", async () => { 181 + const dir = makeSessionDir(); 182 + const name = uniqueName(); 183 + await startDaemon(dir, name); 184 + 185 + const args = Array.from({ length: 30 }, (_, i) => `k${i}=${i}`); 186 + const r = runCli(dir, "tag", name, ...args); 187 + expect(r.status).toBe(0); 188 + const tags = readMeta(dir, name).tags; 189 + expect(Object.keys(tags).length).toBe(30); 190 + for (let i = 0; i < 30; i++) expect(tags[`k${i}`]).toBe(String(i)); 191 + }, 20000); 192 + }); 193 + 194 + describe("pty tag — bulk remove", () => { 195 + it("removes a single key (back-compat)", async () => { 196 + const dir = makeSessionDir(); 197 + const name = uniqueName(); 198 + await startDaemon(dir, name, { a: "1", b: "2" }); 199 + 200 + runCli(dir, "tag", name, "--rm", "a"); 201 + expect(readMeta(dir, name).tags).toEqual({ b: "2" }); 202 + }, 15000); 203 + 204 + it("removes multiple keys in one call", async () => { 205 + const dir = makeSessionDir(); 206 + const name = uniqueName(); 207 + await startDaemon(dir, name, { a: "1", b: "2", c: "3" }); 208 + 209 + runCli(dir, "tag", name, "--rm", "a", "--rm", "c"); 210 + expect(readMeta(dir, name).tags).toEqual({ b: "2" }); 211 + }, 15000); 212 + 213 + it("rm of nonexistent key: silent no-op (no error, exit 0)", async () => { 214 + const dir = makeSessionDir(); 215 + const name = uniqueName(); 216 + await startDaemon(dir, name, { a: "1" }); 217 + 218 + const r = runCli(dir, "tag", name, "--rm", "never-was-set"); 219 + expect(r.status).toBe(0); 220 + expect(readMeta(dir, name).tags).toEqual({ a: "1" }); 221 + }, 15000); 222 + 223 + it("rm of same key twice in one call: idempotent (final: removed)", async () => { 224 + const dir = makeSessionDir(); 225 + const name = uniqueName(); 226 + await startDaemon(dir, name, { dup: "1" }); 227 + 228 + runCli(dir, "tag", name, "--rm", "dup", "--rm", "dup"); 229 + expect(readMeta(dir, name).tags).toBeUndefined(); 230 + }, 15000); 231 + 232 + it("removing every tag drops the `tags` field entirely", async () => { 233 + const dir = makeSessionDir(); 234 + const name = uniqueName(); 235 + await startDaemon(dir, name, { only: "x" }); 236 + 237 + runCli(dir, "tag", name, "--rm", "only"); 238 + expect(readMeta(dir, name).tags).toBeUndefined(); 239 + }, 15000); 240 + }); 241 + 242 + describe("pty tag — combined set + remove", () => { 243 + it("combines set and rm in one call", async () => { 244 + const dir = makeSessionDir(); 245 + const name = uniqueName(); 246 + await startDaemon(dir, name, { keep: "yes", drop: "yes" }); 247 + 248 + runCli(dir, "tag", name, "added=new", "--rm", "drop"); 249 + expect(readMeta(dir, name).tags).toEqual({ keep: "yes", added: "new" }); 250 + }, 15000); 251 + 252 + it("set+rm same key in one call: rm wins (updates apply first, then removals)", async () => { 253 + const dir = makeSessionDir(); 254 + const name = uniqueName(); 255 + await startDaemon(dir, name); 256 + 257 + // Document the contract: `--rm` runs after sets, so this is equivalent 258 + // to "set k=v then immediately remove k" — final state is removed. 259 + runCli(dir, "tag", name, "k=v", "--rm", "k"); 260 + expect(readMeta(dir, name).tags).toBeUndefined(); 261 + }, 15000); 262 + 263 + it("position-independence: set + rm in any order produce the same result", async () => { 264 + const dir = makeSessionDir(); 265 + const a = uniqueName(); 266 + const b = uniqueName(); 267 + await startDaemon(dir, a, { drop: "yes" }); 268 + await startDaemon(dir, b, { drop: "yes" }); 269 + 270 + runCli(dir, "tag", a, "fresh=1", "--rm", "drop", "another=2"); 271 + runCli(dir, "tag", b, "--rm", "drop", "another=2", "fresh=1"); 272 + 273 + expect(readMeta(dir, a).tags).toEqual(readMeta(dir, b).tags); 274 + expect(readMeta(dir, a).tags).toEqual({ fresh: "1", another: "2" }); 275 + }, 15000); 276 + 277 + it("interleaved set / rm / set / rm preserves the apply-updates-first contract", async () => { 278 + const dir = makeSessionDir(); 279 + const name = uniqueName(); 280 + await startDaemon(dir, name, { x: "old", y: "keep" }); 281 + 282 + // Sequence: set x=new, --rm y, set z=new, --rm x. 283 + // Apply order: updates first (x=new, z=new), then removals (rm y, rm x). 284 + // Final: { z: "new" }. y was removed. x was set then removed. 285 + runCli(dir, "tag", name, "x=new", "--rm", "y", "z=new", "--rm", "x"); 286 + expect(readMeta(dir, name).tags).toEqual({ z: "new" }); 287 + }, 15000); 288 + }); 289 + 290 + describe("pty tag — error surface", () => { 291 + it("rejects a positional missing the equals sign", async () => { 292 + const dir = makeSessionDir(); 293 + const name = uniqueName(); 294 + await startDaemon(dir, name); 295 + 296 + const r = runCli(dir, "tag", name, "no-equals-here"); 297 + expect(r.status).not.toBe(0); 298 + expect(r.stderr).toMatch(/key=value|--rm/); 299 + // No write should have happened. 300 + expect(readMeta(dir, name).tags).toBeUndefined(); 301 + }, 15000); 302 + 303 + it("rejects empty-key positional like `=value`", async () => { 304 + const dir = makeSessionDir(); 305 + const name = uniqueName(); 306 + await startDaemon(dir, name); 307 + 308 + const r = runCli(dir, "tag", name, "=value"); 309 + expect(r.status).not.toBe(0); 310 + expect(r.stderr).toMatch(/key/i); 311 + expect(readMeta(dir, name).tags).toBeUndefined(); 312 + }, 15000); 313 + 314 + it("rejects --rm at the end of argv with no key", async () => { 315 + const dir = makeSessionDir(); 316 + const name = uniqueName(); 317 + await startDaemon(dir, name, { keep: "yes" }); 318 + 319 + const r = runCli(dir, "tag", name, "--rm"); 320 + expect(r.status).not.toBe(0); 321 + expect(r.stderr).toMatch(/--rm/); 322 + // Original tags untouched. 323 + expect(readMeta(dir, name).tags).toEqual({ keep: "yes" }); 324 + }, 15000); 325 + 326 + it("rejects --rm with an empty key (`--rm \"\"`)", async () => { 327 + const dir = makeSessionDir(); 328 + const name = uniqueName(); 329 + await startDaemon(dir, name); 330 + 331 + const r = runCli(dir, "tag", name, "--rm", ""); 332 + expect(r.status).not.toBe(0); 333 + expect(r.stderr).toMatch(/key/i); 334 + }, 15000); 335 + 336 + it("errors on a nonexistent session ref", () => { 337 + const dir = makeSessionDir(); 338 + const r = runCli(dir, "tag", "no-such-session", "k=v"); 339 + expect(r.status).not.toBe(0); 340 + expect(r.stderr).toMatch(/not found/); 341 + }, 15000); 342 + 343 + it("rejects bad shape early — no partial application across multiple positionals", async () => { 344 + const dir = makeSessionDir(); 345 + const name = uniqueName(); 346 + await startDaemon(dir, name); 347 + 348 + // A valid set followed by an invalid positional. We want either-all-or- 349 + // nothing semantics: the valid one should NOT land if the call as a 350 + // whole fails. (Atomic at the file level — there's only one writeMetadata 351 + // call, so this is automatically true; this test pins it down.) 352 + const r = runCli(dir, "tag", name, "good=yes", "no-equals"); 353 + expect(r.status).not.toBe(0); 354 + expect(readMeta(dir, name).tags).toBeUndefined(); 355 + }, 15000); 356 + }); 357 + 358 + describe("pty tag — events", () => { 359 + it("bulk write fires exactly one tags_change event", async () => { 360 + const dir = makeSessionDir(); 361 + const name = uniqueName(); 362 + await startDaemon(dir, name); 363 + 364 + const before = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 365 + runCli(dir, "tag", name, "a=1", "b=2", "c=3", "--rm", "z"); 366 + const after = readEvents(dir, name).filter((e) => e.type === "tags_change"); 367 + expect(after.length).toBe(before + 1); 368 + // The single event carries the full snapshot. 369 + const last = after[after.length - 1]; 370 + expect(last.previous).toEqual({}); 371 + expect(last.value).toEqual({ a: "1", b: "2", c: "3" }); 372 + }, 15000); 373 + 374 + it("no-op bulk write fires no event (setting current values + rm of nonexistent)", async () => { 375 + const dir = makeSessionDir(); 376 + const name = uniqueName(); 377 + await startDaemon(dir, name, { stable: "x" }); 378 + 379 + const before = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 380 + // Setting `stable=x` (current value) and removing `nope` (not present). 381 + // Effective tags are unchanged → no event. 382 + runCli(dir, "tag", name, "stable=x", "--rm", "nope"); 383 + const after = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 384 + expect(after).toBe(before); 385 + }, 15000); 386 + 387 + it("partial no-op + real change still fires one event", async () => { 388 + const dir = makeSessionDir(); 389 + const name = uniqueName(); 390 + await startDaemon(dir, name, { same: "x" }); 391 + 392 + const before = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 393 + runCli(dir, "tag", name, "same=x", "new=y"); // same is no-op, new is a real add. 394 + const after = readEvents(dir, name).filter((e) => e.type === "tags_change"); 395 + expect(after.length).toBe(before + 1); 396 + expect(after[after.length - 1].value).toEqual({ same: "x", new: "y" }); 397 + }, 15000); 398 + 399 + it("set+rm same key (rm wins) is still a no-op when the key was never present", async () => { 400 + const dir = makeSessionDir(); 401 + const name = uniqueName(); 402 + await startDaemon(dir, name); 403 + 404 + const before = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 405 + // Set k=v, then --rm k, on a session with no `k` → effective change is 406 + // zero, so no event. 407 + runCli(dir, "tag", name, "k=v", "--rm", "k"); 408 + const after = readEvents(dir, name).filter((e) => e.type === "tags_change").length; 409 + expect(after).toBe(before); 410 + }, 15000); 411 + }); 412 + 413 + describe("pty tag — reads + resolution", () => { 414 + it("no positional args: dumps current tags (back-compat)", async () => { 415 + const dir = makeSessionDir(); 416 + const name = uniqueName(); 417 + await startDaemon(dir, name, { role: "web" }); 418 + 419 + const r = runCli(dir, "tag", name); 420 + expect(r.status).toBe(0); 421 + expect(r.stdout).toContain("role=web"); 422 + }, 15000); 423 + 424 + it("dump on empty: 'No tags' message", async () => { 425 + const dir = makeSessionDir(); 426 + const name = uniqueName(); 427 + await startDaemon(dir, name); 428 + 429 + const r = runCli(dir, "tag", name); 430 + expect(r.stdout).toMatch(/No tags/); 431 + }, 15000); 432 + 433 + it("resolves displayName as the session ref for bulk ops", async () => { 434 + const dir = makeSessionDir(); 435 + const stableId = uniqueName(); 436 + const friendly = `friendly-${Math.random().toString(36).slice(2, 6)}`; 437 + await startDaemon(dir, stableId, undefined, friendly); 438 + 439 + const r = runCli(dir, "tag", friendly, "via=displayname", "another=ok"); 440 + expect(r.status).toBe(0); 441 + expect(readMeta(dir, stableId).tags).toEqual({ 442 + via: "displayname", another: "ok", 443 + }); 444 + }, 15000); 445 + });
+587
tests/tag-multi.test.ts
··· 1 + // `pty tag-multi` — multi-session tag operations (read + write). 2 + // 3 + // Contract recap: 4 + // Selectors (mutually exclusive, pick one): 5 + // <name>... explicit list (resolved before any write) 6 + // --filter-tag k=v ... matching sessions (AND across multiple) 7 + // --all every session 8 + // Read mode = no ops; emit tags per session, JSON or text. 9 + // Write mode = any ops (k=v / --rm k); apply to each selected session. 10 + // --all + write requires --yes. 11 + // Empty match is exit 0 with "0 sessions matched." 12 + // Explicit-list with an unresolvable name: fail upfront, no writes. 13 + // Per-session writes are individually atomic (one tags_change event each). 14 + 15 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 16 + import * as fs from "node:fs"; 17 + import * as os from "node:os"; 18 + import * as path from "node:path"; 19 + import { fileURLToPath } from "node:url"; 20 + import { spawn, spawnSync } from "node:child_process"; 21 + 22 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 23 + const nodeBin = process.execPath; 24 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 25 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 26 + 27 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-tagmulti-")); 28 + afterAll(() => { 29 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 30 + }); 31 + 32 + let bgPids: number[] = []; 33 + let sessionDirs: string[] = []; 34 + 35 + function makeSessionDir(): string { 36 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 37 + sessionDirs.push(dir); 38 + return dir; 39 + } 40 + 41 + let nameCounter = 0; 42 + function uniqueName(): string { 43 + return `tm${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 44 + } 45 + 46 + async function startDaemon( 47 + sessionDir: string, 48 + name: string, 49 + initialTags?: Record<string, string>, 50 + displayName?: string, 51 + ): Promise<number> { 52 + const config = JSON.stringify({ 53 + name, command: "cat", args: [], displayCommand: "cat", 54 + cwd: os.tmpdir(), rows: 24, cols: 80, 55 + ...(initialTags ? { tags: initialTags } : {}), 56 + ...(displayName ? { displayName } : {}), 57 + }); 58 + const child = spawn(nodeBin, [serverModule], { 59 + detached: true, 60 + stdio: ["ignore", "ignore", "pipe"], 61 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 62 + }); 63 + let stderr = ""; 64 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 65 + let exitCode: number | null = null; 66 + child.on("exit", (code) => { exitCode = code; }); 67 + (child.stderr as any)?.unref?.(); 68 + child.unref(); 69 + const socketPath = path.join(sessionDir, `${name}.sock`); 70 + const start = Date.now(); 71 + while (Date.now() - start < 5000) { 72 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 73 + try { 74 + fs.statSync(socketPath); 75 + await new Promise((r) => setTimeout(r, 100)); 76 + bgPids.push(child.pid!); 77 + return child.pid!; 78 + } catch {} 79 + await new Promise((r) => setTimeout(r, 50)); 80 + } 81 + throw new Error("Timeout waiting for daemon"); 82 + } 83 + 84 + function runCli(sessionDir: string, ...args: string[]) { 85 + return spawnSync(nodeBin, [cliPath, ...args], { 86 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 87 + encoding: "utf-8", 88 + timeout: 10_000, 89 + }); 90 + } 91 + 92 + function readMeta(sessionDir: string, name: string) { 93 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 94 + } 95 + 96 + function readEvents(dir: string, name: string): any[] { 97 + try { 98 + const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 99 + return content.trimEnd().split("\n").filter(Boolean).map((l) => JSON.parse(l)); 100 + } catch { return []; } 101 + } 102 + 103 + afterEach(() => { 104 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 105 + bgPids = []; 106 + for (const dir of sessionDirs) { 107 + try { 108 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 109 + } catch {} 110 + } 111 + sessionDirs = []; 112 + }); 113 + 114 + // ============================================================================= 115 + // READ MODE — explicit list 116 + // ============================================================================= 117 + 118 + describe("pty tag-multi — read mode (explicit list)", () => { 119 + it("dumps tags for a single named session", async () => { 120 + const dir = makeSessionDir(); 121 + const a = uniqueName(); 122 + await startDaemon(dir, a, { role: "web" }); 123 + 124 + const r = runCli(dir, "tag-multi", a); 125 + expect(r.status).toBe(0); 126 + expect(r.stdout).toContain(a); 127 + expect(r.stdout).toContain("role=web"); 128 + }, 15000); 129 + 130 + it("dumps tags for multiple named sessions in argv order", async () => { 131 + const dir = makeSessionDir(); 132 + const a = uniqueName(); 133 + const b = uniqueName(); 134 + await startDaemon(dir, a, { role: "web" }); 135 + await startDaemon(dir, b, { role: "db" }); 136 + 137 + const r = runCli(dir, "tag-multi", a, b); 138 + expect(r.status).toBe(0); 139 + expect(r.stdout).toContain(a); 140 + expect(r.stdout).toContain(b); 141 + expect(r.stdout).toContain("role=web"); 142 + expect(r.stdout).toContain("role=db"); 143 + }, 15000); 144 + 145 + it("--json emits an object keyed by session name", async () => { 146 + const dir = makeSessionDir(); 147 + const a = uniqueName(); 148 + const b = uniqueName(); 149 + await startDaemon(dir, a, { role: "web" }); 150 + await startDaemon(dir, b, { env: "dev" }); 151 + 152 + const r = runCli(dir, "tag-multi", a, b, "--json"); 153 + expect(r.status).toBe(0); 154 + const parsed = JSON.parse(r.stdout); 155 + expect(parsed[a]).toEqual({ role: "web" }); 156 + expect(parsed[b]).toEqual({ env: "dev" }); 157 + }, 15000); 158 + 159 + it("a session without tags renders as an empty object in JSON", async () => { 160 + const dir = makeSessionDir(); 161 + const a = uniqueName(); 162 + await startDaemon(dir, a); 163 + 164 + const r = runCli(dir, "tag-multi", a, "--json"); 165 + expect(r.status).toBe(0); 166 + expect(JSON.parse(r.stdout)).toEqual({ [a]: {} }); 167 + }, 15000); 168 + 169 + it("resolves displayNames in the explicit list", async () => { 170 + const dir = makeSessionDir(); 171 + const stableId = uniqueName(); 172 + const friendly = `friendly-${Math.random().toString(36).slice(2, 6)}`; 173 + await startDaemon(dir, stableId, { role: "web" }, friendly); 174 + 175 + const r = runCli(dir, "tag-multi", friendly, "--json"); 176 + expect(r.status).toBe(0); 177 + // Resolved key in the output is the stable id, not the displayName. 178 + expect(JSON.parse(r.stdout)).toEqual({ [stableId]: { role: "web" } }); 179 + }, 15000); 180 + 181 + it("errors out with no writes when one of the explicit names is unresolvable", async () => { 182 + const dir = makeSessionDir(); 183 + const a = uniqueName(); 184 + await startDaemon(dir, a); 185 + 186 + const r = runCli(dir, "tag-multi", a, "no-such-session"); 187 + expect(r.status).not.toBe(0); 188 + expect(r.stderr).toMatch(/not found|no-such-session/); 189 + }, 15000); 190 + }); 191 + 192 + // ============================================================================= 193 + // READ MODE — selectors 194 + // ============================================================================= 195 + 196 + describe("pty tag-multi — read mode (selectors)", () => { 197 + it("--filter-tag matches sessions with that tag", async () => { 198 + const dir = makeSessionDir(); 199 + const a = uniqueName(); 200 + const b = uniqueName(); 201 + const c = uniqueName(); 202 + await startDaemon(dir, a, { role: "web" }); 203 + await startDaemon(dir, b, { role: "db" }); 204 + await startDaemon(dir, c, { role: "web" }); 205 + 206 + const r = runCli(dir, "tag-multi", "--filter-tag", "role=web", "--json"); 207 + expect(r.status).toBe(0); 208 + const parsed = JSON.parse(r.stdout); 209 + expect(Object.keys(parsed).sort()).toEqual([a, c].sort()); 210 + }, 20000); 211 + 212 + it("multiple --filter-tag ANDs the conditions", async () => { 213 + const dir = makeSessionDir(); 214 + const a = uniqueName(); 215 + const b = uniqueName(); 216 + await startDaemon(dir, a, { role: "web", env: "prod" }); 217 + await startDaemon(dir, b, { role: "web", env: "dev" }); 218 + 219 + const r = runCli(dir, "tag-multi", "--filter-tag", "role=web", "--filter-tag", "env=prod", "--json"); 220 + expect(r.status).toBe(0); 221 + expect(JSON.parse(r.stdout)).toEqual({ [a]: { role: "web", env: "prod" } }); 222 + }, 15000); 223 + 224 + it("--filter-tag with zero matches: exit 0, empty JSON object", async () => { 225 + const dir = makeSessionDir(); 226 + const a = uniqueName(); 227 + await startDaemon(dir, a, { role: "web" }); 228 + 229 + const r = runCli(dir, "tag-multi", "--filter-tag", "role=ghost", "--json"); 230 + expect(r.status).toBe(0); 231 + expect(JSON.parse(r.stdout)).toEqual({}); 232 + }, 15000); 233 + 234 + it("--all emits every session (read is safe, no --yes needed)", async () => { 235 + const dir = makeSessionDir(); 236 + const a = uniqueName(); 237 + const b = uniqueName(); 238 + await startDaemon(dir, a, { role: "web" }); 239 + await startDaemon(dir, b, { role: "db" }); 240 + 241 + const r = runCli(dir, "tag-multi", "--all", "--json"); 242 + expect(r.status).toBe(0); 243 + const parsed = JSON.parse(r.stdout); 244 + expect(Object.keys(parsed).sort()).toEqual([a, b].sort()); 245 + }, 15000); 246 + 247 + it("--all on an empty session dir: exit 0, empty JSON", () => { 248 + const dir = makeSessionDir(); 249 + const r = runCli(dir, "tag-multi", "--all", "--json"); 250 + expect(r.status).toBe(0); 251 + expect(JSON.parse(r.stdout)).toEqual({}); 252 + }, 15000); 253 + }); 254 + 255 + // ============================================================================= 256 + // WRITE MODE — explicit list 257 + // ============================================================================= 258 + 259 + describe("pty tag-multi — write mode (explicit list)", () => { 260 + it("sets a tag on each named session", async () => { 261 + const dir = makeSessionDir(); 262 + const a = uniqueName(); 263 + const b = uniqueName(); 264 + await startDaemon(dir, a); 265 + await startDaemon(dir, b); 266 + 267 + const r = runCli(dir, "tag-multi", a, b, "audit=2026-04-25"); 268 + expect(r.status).toBe(0); 269 + 270 + expect(readMeta(dir, a).tags).toEqual({ audit: "2026-04-25" }); 271 + expect(readMeta(dir, b).tags).toEqual({ audit: "2026-04-25" }); 272 + }, 15000); 273 + 274 + it("removes a tag on each named session", async () => { 275 + const dir = makeSessionDir(); 276 + const a = uniqueName(); 277 + const b = uniqueName(); 278 + await startDaemon(dir, a, { role: "web", env: "prod" }); 279 + await startDaemon(dir, b, { role: "web" }); 280 + 281 + const r = runCli(dir, "tag-multi", a, b, "--rm", "role"); 282 + expect(r.status).toBe(0); 283 + 284 + expect(readMeta(dir, a).tags).toEqual({ env: "prod" }); 285 + expect(readMeta(dir, b).tags).toBeUndefined(); 286 + }, 15000); 287 + 288 + it("combined set + rm in one call applies to each session", async () => { 289 + const dir = makeSessionDir(); 290 + const a = uniqueName(); 291 + const b = uniqueName(); 292 + await startDaemon(dir, a, { drop: "yes" }); 293 + await startDaemon(dir, b, { drop: "yes" }); 294 + 295 + runCli(dir, "tag-multi", a, b, "fresh=1", "--rm", "drop"); 296 + expect(readMeta(dir, a).tags).toEqual({ fresh: "1" }); 297 + expect(readMeta(dir, b).tags).toEqual({ fresh: "1" }); 298 + }, 15000); 299 + 300 + it("each successful write fires its own tags_change event", async () => { 301 + const dir = makeSessionDir(); 302 + const a = uniqueName(); 303 + const b = uniqueName(); 304 + await startDaemon(dir, a); 305 + await startDaemon(dir, b); 306 + 307 + runCli(dir, "tag-multi", a, b, "role=web"); 308 + const aEvts = readEvents(dir, a).filter((e) => e.type === "tags_change"); 309 + const bEvts = readEvents(dir, b).filter((e) => e.type === "tags_change"); 310 + expect(aEvts.length).toBe(1); 311 + expect(bEvts.length).toBe(1); 312 + expect(aEvts[0].value).toEqual({ role: "web" }); 313 + expect(bEvts[0].value).toEqual({ role: "web" }); 314 + }, 15000); 315 + 316 + it("no-op write on a session emits no event for that session, others fire normally", async () => { 317 + const dir = makeSessionDir(); 318 + const a = uniqueName(); 319 + const b = uniqueName(); 320 + await startDaemon(dir, a, { role: "web" }); // setting role=web is a no-op for a 321 + await startDaemon(dir, b); // real change for b 322 + 323 + const beforeA = readEvents(dir, a).filter((e) => e.type === "tags_change").length; 324 + const beforeB = readEvents(dir, b).filter((e) => e.type === "tags_change").length; 325 + 326 + runCli(dir, "tag-multi", a, b, "role=web"); 327 + 328 + expect(readEvents(dir, a).filter((e) => e.type === "tags_change").length).toBe(beforeA); 329 + expect(readEvents(dir, b).filter((e) => e.type === "tags_change").length).toBe(beforeB + 1); 330 + }, 15000); 331 + 332 + it("upfront unresolvable name: no writes happen on any session", async () => { 333 + const dir = makeSessionDir(); 334 + const a = uniqueName(); 335 + await startDaemon(dir, a); 336 + 337 + const r = runCli(dir, "tag-multi", a, "no-such", "role=web"); 338 + expect(r.status).not.toBe(0); 339 + // Must not have written to `a` even though `a` was resolvable. 340 + expect(readMeta(dir, a).tags).toBeUndefined(); 341 + }, 15000); 342 + 343 + it("resolves displayName for each session in the list", async () => { 344 + const dir = makeSessionDir(); 345 + const aId = uniqueName(); 346 + const bId = uniqueName(); 347 + const aFriendly = `f1-${Math.random().toString(36).slice(2, 6)}`; 348 + const bFriendly = `f2-${Math.random().toString(36).slice(2, 6)}`; 349 + await startDaemon(dir, aId, undefined, aFriendly); 350 + await startDaemon(dir, bId, undefined, bFriendly); 351 + 352 + runCli(dir, "tag-multi", aFriendly, bFriendly, "role=web"); 353 + expect(readMeta(dir, aId).tags).toEqual({ role: "web" }); 354 + expect(readMeta(dir, bId).tags).toEqual({ role: "web" }); 355 + }, 15000); 356 + }); 357 + 358 + // ============================================================================= 359 + // WRITE MODE — selectors 360 + // ============================================================================= 361 + 362 + describe("pty tag-multi — write mode (selectors)", () => { 363 + it("--filter-tag writes to each matching session", async () => { 364 + const dir = makeSessionDir(); 365 + const a = uniqueName(); 366 + const b = uniqueName(); 367 + const c = uniqueName(); 368 + await startDaemon(dir, a, { role: "web" }); 369 + await startDaemon(dir, b, { role: "db" }); 370 + await startDaemon(dir, c, { role: "web" }); 371 + 372 + runCli(dir, "tag-multi", "--filter-tag", "role=web", "audit=2026-04-25"); 373 + 374 + expect(readMeta(dir, a).tags).toEqual({ role: "web", audit: "2026-04-25" }); 375 + expect(readMeta(dir, b).tags).toEqual({ role: "db" }); // not matched 376 + expect(readMeta(dir, c).tags).toEqual({ role: "web", audit: "2026-04-25" }); 377 + }, 20000); 378 + 379 + it("--filter-tag matching zero sessions: exit 0, no writes", async () => { 380 + const dir = makeSessionDir(); 381 + const a = uniqueName(); 382 + await startDaemon(dir, a, { role: "web" }); 383 + 384 + const r = runCli(dir, "tag-multi", "--filter-tag", "role=ghost", "x=1"); 385 + expect(r.status).toBe(0); 386 + expect(readMeta(dir, a).tags).toEqual({ role: "web" }); // untouched 387 + }, 15000); 388 + 389 + it("--all without --yes is rejected (write mode is destructive across every session)", async () => { 390 + const dir = makeSessionDir(); 391 + const a = uniqueName(); 392 + await startDaemon(dir, a); 393 + 394 + const r = runCli(dir, "tag-multi", "--all", "role=web"); 395 + expect(r.status).not.toBe(0); 396 + expect(r.stderr).toMatch(/--yes/); 397 + // Untouched. 398 + expect(readMeta(dir, a).tags).toBeUndefined(); 399 + }, 15000); 400 + 401 + it("--all --yes applies to every session", async () => { 402 + const dir = makeSessionDir(); 403 + const a = uniqueName(); 404 + const b = uniqueName(); 405 + await startDaemon(dir, a); 406 + await startDaemon(dir, b); 407 + 408 + const r = runCli(dir, "tag-multi", "--all", "--yes", "stamped=1"); 409 + expect(r.status).toBe(0); 410 + expect(readMeta(dir, a).tags).toEqual({ stamped: "1" }); 411 + expect(readMeta(dir, b).tags).toEqual({ stamped: "1" }); 412 + }, 15000); 413 + 414 + it("-y short form works the same as --yes", async () => { 415 + const dir = makeSessionDir(); 416 + const a = uniqueName(); 417 + await startDaemon(dir, a); 418 + 419 + const r = runCli(dir, "tag-multi", "--all", "-y", "role=web"); 420 + expect(r.status).toBe(0); 421 + expect(readMeta(dir, a).tags).toEqual({ role: "web" }); 422 + }, 15000); 423 + }); 424 + 425 + // ============================================================================= 426 + // SELECTOR MUTEX 427 + // ============================================================================= 428 + 429 + describe("pty tag-multi — selector mutex", () => { 430 + it("rejects --all combined with --filter-tag", () => { 431 + const dir = makeSessionDir(); 432 + const r = runCli(dir, "tag-multi", "--all", "--filter-tag", "k=v"); 433 + expect(r.status).not.toBe(0); 434 + expect(r.stderr).toMatch(/mutually exclusive|pick one/i); 435 + }); 436 + 437 + it("rejects --all combined with explicit names", async () => { 438 + const dir = makeSessionDir(); 439 + const a = uniqueName(); 440 + await startDaemon(dir, a); 441 + 442 + const r = runCli(dir, "tag-multi", "--all", a); 443 + expect(r.status).not.toBe(0); 444 + expect(r.stderr).toMatch(/mutually exclusive|pick one/i); 445 + }, 15000); 446 + 447 + it("rejects --filter-tag combined with explicit names", async () => { 448 + const dir = makeSessionDir(); 449 + const a = uniqueName(); 450 + await startDaemon(dir, a); 451 + 452 + const r = runCli(dir, "tag-multi", "--filter-tag", "k=v", a); 453 + expect(r.status).not.toBe(0); 454 + expect(r.stderr).toMatch(/mutually exclusive|pick one/i); 455 + }, 15000); 456 + 457 + it("rejects no selector at all", () => { 458 + const dir = makeSessionDir(); 459 + const r = runCli(dir, "tag-multi"); 460 + expect(r.status).not.toBe(0); 461 + expect(r.stderr).toMatch(/selector/i); 462 + }); 463 + 464 + it("rejects ops with no selector (e.g. just `pty tag-multi role=web`)", () => { 465 + const dir = makeSessionDir(); 466 + const r = runCli(dir, "tag-multi", "role=web"); 467 + expect(r.status).not.toBe(0); 468 + expect(r.stderr).toMatch(/selector/i); 469 + }); 470 + }); 471 + 472 + // ============================================================================= 473 + // OPS PARSING — error surface (mirrors pty tag) 474 + // ============================================================================= 475 + 476 + describe("pty tag-multi — ops parsing errors", () => { 477 + it("rejects empty key in =value", async () => { 478 + const dir = makeSessionDir(); 479 + const a = uniqueName(); 480 + await startDaemon(dir, a); 481 + 482 + const r = runCli(dir, "tag-multi", a, "=value"); 483 + expect(r.status).not.toBe(0); 484 + expect(r.stderr).toMatch(/key/i); 485 + expect(readMeta(dir, a).tags).toBeUndefined(); 486 + }, 15000); 487 + 488 + it("rejects --rm at end without a key", async () => { 489 + const dir = makeSessionDir(); 490 + const a = uniqueName(); 491 + await startDaemon(dir, a, { keep: "yes" }); 492 + 493 + const r = runCli(dir, "tag-multi", a, "--rm"); 494 + expect(r.status).not.toBe(0); 495 + expect(r.stderr).toMatch(/--rm/); 496 + expect(readMeta(dir, a).tags).toEqual({ keep: "yes" }); 497 + }, 15000); 498 + 499 + it("rejects --rm with empty-string key", async () => { 500 + const dir = makeSessionDir(); 501 + const a = uniqueName(); 502 + await startDaemon(dir, a); 503 + 504 + const r = runCli(dir, "tag-multi", a, "--rm", ""); 505 + expect(r.status).not.toBe(0); 506 + expect(r.stderr).toMatch(/key/i); 507 + }, 15000); 508 + 509 + it("rejects --filter-tag without a value", () => { 510 + const dir = makeSessionDir(); 511 + const r = runCli(dir, "tag-multi", "--filter-tag"); 512 + expect(r.status).not.toBe(0); 513 + expect(r.stderr).toMatch(/--filter-tag|k=v/i); 514 + }); 515 + 516 + it("rejects --filter-tag with no equals sign", () => { 517 + const dir = makeSessionDir(); 518 + const r = runCli(dir, "tag-multi", "--filter-tag", "no-equals"); 519 + expect(r.status).not.toBe(0); 520 + expect(r.stderr).toMatch(/filter|k=v/i); 521 + }); 522 + 523 + it("multi-= splits on first equals (key='foo', value='bar=baz')", async () => { 524 + const dir = makeSessionDir(); 525 + const a = uniqueName(); 526 + await startDaemon(dir, a); 527 + 528 + runCli(dir, "tag-multi", a, "foo=bar=baz"); 529 + expect(readMeta(dir, a).tags).toEqual({ foo: "bar=baz" }); 530 + }, 15000); 531 + 532 + it("set+rm same key in one call: rm wins (matches pty tag contract)", async () => { 533 + const dir = makeSessionDir(); 534 + const a = uniqueName(); 535 + await startDaemon(dir, a); 536 + 537 + runCli(dir, "tag-multi", a, "k=v", "--rm", "k"); 538 + expect(readMeta(dir, a).tags).toBeUndefined(); 539 + }, 15000); 540 + }); 541 + 542 + // ============================================================================= 543 + // MISC 544 + // ============================================================================= 545 + 546 + describe("pty tag-multi — misc", () => { 547 + it("read of an explicit list with mixed tagged/untagged sessions is consistent", async () => { 548 + const dir = makeSessionDir(); 549 + const a = uniqueName(); 550 + const b = uniqueName(); 551 + const c = uniqueName(); 552 + await startDaemon(dir, a, { role: "web" }); 553 + await startDaemon(dir, b); 554 + await startDaemon(dir, c, { env: "prod" }); 555 + 556 + const r = runCli(dir, "tag-multi", a, b, c, "--json"); 557 + expect(r.status).toBe(0); 558 + expect(JSON.parse(r.stdout)).toEqual({ 559 + [a]: { role: "web" }, 560 + [b]: {}, 561 + [c]: { env: "prod" }, 562 + }); 563 + }, 20000); 564 + 565 + it("write to an empty list (--filter-tag matches nothing) emits no events", async () => { 566 + const dir = makeSessionDir(); 567 + const a = uniqueName(); 568 + await startDaemon(dir, a, { role: "web" }); 569 + 570 + const before = readEvents(dir, a).length; 571 + runCli(dir, "tag-multi", "--filter-tag", "role=ghost", "x=1"); 572 + const after = readEvents(dir, a).length; 573 + expect(after).toBe(before); 574 + }, 15000); 575 + 576 + it("--all read is consistent with summing per-name reads", async () => { 577 + const dir = makeSessionDir(); 578 + const a = uniqueName(); 579 + const b = uniqueName(); 580 + await startDaemon(dir, a, { role: "web" }); 581 + await startDaemon(dir, b, { env: "prod" }); 582 + 583 + const all = JSON.parse(runCli(dir, "tag-multi", "--all", "--json").stdout); 584 + const explicit = JSON.parse(runCli(dir, "tag-multi", a, b, "--json").stdout); 585 + expect(all).toEqual(explicit); 586 + }, 15000); 587 + });