···2233## Unreleased
4455+### Interactive TUI
66+- Add `--preselect-new` flag: `pty --preselect-new` opens the interactive TUI with "Create new session..." pre-selected (useful for pty-layout panes that should land on the create prompt)
77+- Add `--filter-tag key=value` flag (repeatable): filters the TUI to sessions matching all given tags AND auto-applies those tags to any session created from this TUI instance — so new sessions stay in the filtered view (e.g., pty-layout layouts)
88+- Tag filter is shown in the Filter line; remote groups are hidden when a tag filter is active (remote session tags aren't surfaced by pty-relay)
99+1010+### Listing
1111+- `pty list` now shows tags by default (hashtag format, e.g., `#role=web`) — internal bookkeeping keys (`ptyfile*`, `strategy`, `supervisor.status`) are hidden
1212+- `pty list --tags` now means "show all tags including internal bookkeeping" (previously required to show any tags)
1313+- Add `pty list --filter-tag key=value` (repeatable): show only sessions matching all given tags
1414+1515+### Client API
1616+- Export `extractFilterTags` and `matchesAllTags` from `@myobie/pty/client` so third-party tools (e.g., pty-relay) can accept and apply the same `--filter-tag key=value` syntax
1717+- Add optional `tags?: Record<string, string>` on remote session entries so pty-relay can surface tags in `ls --json` and have the interactive TUI filter remote sessions by them
1818+1919+### Project files
2020+- `pty up` now removes tags that were removed from `pty.toml` — toml-managed tag keys are tracked in a `ptyfile.tags` meta tag so manually-added tags (set via `pty tag`) are preserved
2121+522### Fixes
623- Fix garbage characters in `less`/`git log`: respond to terminal queries (OSC 10/11/4, DA2, DSR, XTVERSION) and strip them from client broadcast so the client's terminal doesn't respond with duplicate input
724
+6-2
README.md
···48484949```sh
5050pty # interactive session manager
5151+pty --preselect-new # open the TUI with "Create new session..." selected
5252+pty --filter-tag layout=work # TUI filtered by tag; new sessions inherit the tag
5153pty run -- node server.js # start a session (auto-named)
5254pty run --name myserver -- node server.js # start with an explicit name
5355pty run -d -- node server.js # start in the background
···5658pty run --tag owner=forge -- node srv.js # tag a session with metadata
5759pty run --cwd /path -- node server.js # run in a specific directory
58605959-pty list # show active sessions
6060-pty list --tags # show sessions with tags (#key=value)
6161+pty list # show active sessions (tags shown by default)
6262+pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.)
6163pty list --json # show as JSON
6264pty list --remote # include remote sessions via pty-relay
6565+pty list --filter-tag role=web # show only sessions with matching tag (repeatable)
63666467pty attach myserver # reconnect to a session
6568pty attach -r myserver # reconnect, auto-restart if exited
···209212 spawnDaemon, listSessions, getSession,
210213 SessionConnection, sendData, peekScreen, queryStats,
211214 EventFollower, readRecentEvents,
215215+ extractFilterTags, matchesAllTags,
212216} from "@myobie/pty/client";
213217import { PtyServer } from "@myobie/pty/server"; // native addon (node-pty)
214218import { resolveKey } from "@myobie/pty/keys"; // browser-safe
+106-34
src/cli.ts
···2424import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts";
2525import { readPtyFile, type PtySessionDef } from "./ptyfile.ts";
2626import { getSupervisorDir } from "./supervisor.ts";
2727+import { extractFilterTags as extractFilterTagsImpl, matchesAllTags } from "./tags.ts";
27282829// Lazy-load the interactive TUI so non-interactive commands don't crash when
2930// the caller's cwd was deleted (the TUI module evaluates process.cwd() at load).
3030-async function runInteractive(): Promise<void> {
3131+async function runInteractive(options?: { preselectNew?: boolean; filterTags?: Record<string, string> }): Promise<void> {
3132 const mod = await import("./tui/interactive.ts");
3232- await mod.runInteractive();
3333+ await mod.runInteractive(options);
3434+}
3535+3636+/** CLI wrapper around `extractFilterTags` that exits on invalid input. */
3737+function extractFilterTags(args: string[]): Record<string, string> {
3838+ try {
3939+ return extractFilterTagsImpl(args);
4040+ } catch (e: any) {
4141+ console.error(e.message);
4242+ process.exit(1);
4343+ }
3344}
34453546function usage(): void {
3647 console.log(`Usage:
3748 pty Interactive session manager
4949+ pty --preselect-new Open the TUI with "Create new session..." pre-selected
5050+ pty --filter-tag key=value Filter TUI to sessions with a tag; auto-applied to new sessions
3851 pty run -- <command> [args...] Create a session and attach (auto-named)
3952 pty run --name <n> -- <command> [args...] Create a named session and attach
4053 pty run -d -- <command> [args...] Create in the background
···5972 pty events --all Follow events from all sessions
6073 pty events --recent <name> Show recent events and exit
6174 pty events --json <name> Output raw JSONL
6262- pty list List active sessions
6363- pty list --tags List sessions with tags (#key=value)
7575+ pty list List active sessions (with tags)
7676+ pty list --tags Show all tags including internal bookkeeping (ptyfile*, strategy, etc.)
6477 pty list --json List sessions as JSON
7878+ pty list --filter-tag key=value List only sessions matching the tag (repeatable)
6579 pty up Start all sessions from pty.toml
6680 pty up <dir> Start sessions from <dir>/pty.toml
6781 pty up <name> [<name>...] Start specific sessions from pty.toml
···111125async function main(): Promise<void> {
112126 const args = process.argv.slice(2);
113127114114- if (args.length === 0) {
115115- await runInteractive();
128128+ // Interactive-mode flags (--preselect-new, --filter-tag) can appear before
129129+ // the subcommand. Peek at the subcommand without consuming flags; if it's
130130+ // the interactive TUI (none, "i", or "interactive"), consume those flags
131131+ // here. Otherwise leave them in args for the subcommand to parse itself.
132132+ //
133133+ // Detect the subcommand: first positional that isn't a flag or a value for
134134+ // a known flag that takes a value (currently just --filter-tag).
135135+ let subcommand = "";
136136+ for (let i = 0; i < args.length; i++) {
137137+ const a = args[i];
138138+ if (a === "--filter-tag") { i++; continue; }
139139+ if (a.startsWith("-")) continue;
140140+ subcommand = a;
141141+ break;
142142+ }
143143+144144+ let preselectNew = false;
145145+ let interactiveFilterTags: Record<string, string> = {};
146146+ if (!subcommand || subcommand === "i" || subcommand === "interactive") {
147147+ preselectNew = args.includes("--preselect-new");
148148+ interactiveFilterTags = extractFilterTags(args);
149149+ }
150150+ const dispatchArgs = args.filter((a) => a !== "--preselect-new");
151151+152152+ if (dispatchArgs.length === 0) {
153153+ await runInteractive({ preselectNew, filterTags: interactiveFilterTags });
116154 return;
117155 }
118156119119- const command = args[0];
157157+ const command = dispatchArgs[0];
120158121159 switch (command) {
122160 case "interactive":
123161 case "i": {
124124- await runInteractive();
162162+ await runInteractive({ preselectNew, filterTags: interactiveFilterTags });
125163 break;
126164 }
127165···401439402440 case "list":
403441 case "ls": {
404404- const jsonFlag = args.includes("--json");
405405- const tagsFlag = args.includes("--tags");
406406- const remoteFlag = args.includes("--remote");
407407- await cmdList(jsonFlag, tagsFlag, remoteFlag);
442442+ const listArgs = args.slice();
443443+ const listFilterTags = extractFilterTags(listArgs);
444444+ const jsonFlag = listArgs.includes("--json");
445445+ const tagsFlag = listArgs.includes("--tags");
446446+ const remoteFlag = listArgs.includes("--remote");
447447+ await cmdList(jsonFlag, tagsFlag, remoteFlag, listFilterTags);
408448 break;
409449 }
410450···9691009 });
9701010}
9711011972972-async function cmdList(json = false, showTags = false, remote = false): Promise<void> {
973973- const sessions = await listSessions();
10121012+async function cmdList(json = false, showTags = false, remote = false, filterTags: Record<string, string> = {}): Promise<void> {
10131013+ let sessions = await listSessions();
10141014+ if (Object.keys(filterTags).length > 0) {
10151015+ sessions = sessions.filter((s) => matchesAllTags(s.metadata?.tags, filterTags));
10161016+ }
97410179751018 // Fetch relay hosts if --remote
9761019 let remoteHosts: { label: string; sessions: { name: string; status: string; command?: string; cwd?: string }[]; error: string | null }[] = [];
···10141057 const running = sessions.filter((s) => s.status === "running");
10151058 const exited = sessions.filter((s) => s.status === "exited");
1016105910601060+ // Render tags as hashtags. When `showAll` is false, hide internal bookkeeping
10611061+ // keys (ptyfile*, supervisor.status) since those have dedicated markers or
10621062+ // aren't meaningful to users. `--tags` (showAll=true) includes everything.
10631063+ const renderTags = (tags: Record<string, string> | undefined, showAll: boolean): string => {
10641064+ if (!tags) return "";
10651065+ const entries = Object.entries(tags).filter(([k]) =>
10661066+ showAll || (k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && k !== "supervisor.status" && k !== "strategy"),
10671067+ );
10681068+ return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : "";
10691069+ };
10701070+10171071 if (running.length > 0) {
10181072 console.log("Active sessions:");
10191073 for (const session of running) {
···10231077 const cwd = session.metadata?.cwd
10241078 ? shortPath(session.metadata.cwd)
10251079 : "";
10261026- const tagStr = showTags && session.metadata?.tags
10271027- ? " " + Object.entries(session.metadata.tags).map(([k, v]) => `#${k}=${v}`).join(" ")
10281028- : "";
10801080+ const tagStr = renderTags(session.metadata?.tags, showTags);
10291081 const marker = strategyMarker(session.metadata?.tags);
10301082 console.log(` \x1b[1;36m${session.name}\x1b[0m${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`);
10311083 }
···10421094 const cmd = meta
10431095 ? meta.displayCommand
10441096 : "";
10451045- const tagStr = showTags && meta?.tags
10461046- ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ")
10471047- : "";
10971097+ const tagStr = renderTags(meta?.tags, showTags);
10481098 const marker = strategyMarker(meta?.tags);
10491099 console.log(` \x1b[1m${session.name}\x1b[0m${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`);
10501100 }
···17201770 let skipped = 0;
1721177117221772 for (const sess of sessions) {
17731773+ const tomlPath = path.join(ptyFile.dir, "pty.toml");
17741774+ const userTomlKeys = Object.keys(sess.tags ?? {}).sort();
17751775+ const ptyfileTagsValue = userTomlKeys.join(",");
17761776+ const tomlTags: Record<string, string> = {
17771777+ ...sess.tags,
17781778+ ptyfile: tomlPath,
17791779+ "ptyfile.session": sess.shortName,
17801780+ "ptyfile.tags": ptyfileTagsValue,
17811781+ };
17821782+17231783 if (runningNames.has(sess.name)) {
17241724- // Sync tags from toml to the running session (including ptyfile metadata)
17841784+ // Sync tags from toml to the running session (including ptyfile metadata).
17851785+ // Track which tag keys came from the toml via "ptyfile.tags" so that
17861786+ // removing a tag from the toml causes it to be removed here (but
17871787+ // manually-added tags — those not in "ptyfile.tags" — are preserved).
17251788 const currentMeta = readMetadata(sess.name);
17261726- const tomlPath = path.join(ptyFile.dir, "pty.toml");
17271727- const tomlTags = { ...sess.tags, ptyfile: tomlPath, "ptyfile.session": sess.shortName };
17281789 const currentTags = currentMeta?.tags ?? {};
17901790+17291791 const updates: Record<string, string> = {};
17301792 for (const [k, v] of Object.entries(tomlTags)) {
17311793 if (currentTags[k] !== v) updates[k] = v;
17321794 }
17331733- if (Object.keys(updates).length > 0) {
17951795+17961796+ const prevTomlKeys = (currentTags["ptyfile.tags"] ?? "")
17971797+ .split(",")
17981798+ .map((k) => k.trim())
17991799+ .filter((k) => k.length > 0);
18001800+ const newKeySet = new Set(userTomlKeys);
18011801+ const removals = prevTomlKeys.filter((k) => !newKeySet.has(k));
18021802+18031803+ if (Object.keys(updates).length > 0 || removals.length > 0) {
17341804 try {
17351735- updateTags(sess.name, updates);
17361736- const changed = Object.entries(updates).map(([k, v]) => `${k}=${v}`).join(", ");
17371737- console.log(` ● ${sess.name} (already running, updated tags: ${changed})`);
18051805+ updateTags(sess.name, updates, removals);
18061806+ const changedTagUpdates = Object.entries(updates)
18071807+ .filter(([k]) => k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags")
18081808+ .map(([k, v]) => `${k}=${v}`);
18091809+ const changedRemovals = removals.map((k) => `-${k}`);
18101810+ const changed = [...changedTagUpdates, ...changedRemovals].join(", ");
18111811+ if (changed) {
18121812+ console.log(` ● ${sess.name} (already running, updated tags: ${changed})`);
18131813+ } else {
18141814+ console.log(` ● ${sess.name} (already running)`);
18151815+ }
17381816 } catch {
17391817 console.log(` ● ${sess.name} (already running)`);
17401818 }
···17521830 }
1753183117541832 try {
17551755- const tomlPath = path.join(ptyFile.dir, "pty.toml");
17561756- const tags = {
17571757- ...sess.tags,
17581758- ptyfile: tomlPath,
17591759- "ptyfile.session": sess.shortName,
17601760- };
17611833 await spawnDaemon({
17621834 name: sess.name,
17631835 command: "/bin/sh",
17641836 args: ["-c", sess.command],
17651837 displayCommand: sess.command,
17661838 cwd: ptyFile.dir,
17671767- tags,
18391839+ tags: tomlTags,
17681840 });
17691841 console.log(` ● ${sess.name} (started)`);
17701842 started++;
+3
src/client-api.ts
···4242// Project files
4343export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts";
44444545+// Tag filter helpers (used by --filter-tag; shared with pty-relay)
4646+export { extractFilterTags, matchesAllTags } from "./tags.ts";
4747+4548// Keys
4649export { resolveKey, parseSeqValue } from "./keys.ts";
4750
+46
src/tags.ts
···11+/**
22+ * Shared helpers for working with session tag filters.
33+ *
44+ * These are used by the pty CLI (`pty list --filter-tag`, `pty --filter-tag`)
55+ * and are exposed on `@myobie/pty/client` so tools like pty-relay can accept
66+ * and apply the same filter syntax.
77+ */
88+99+/**
1010+ * Extract all `--filter-tag key=value` pairs from `args` (repeatable).
1111+ *
1212+ * Mutates `args`: consumed `--filter-tag` and its value are removed in place.
1313+ * Returns the collected tags as an object.
1414+ *
1515+ * Throws if `--filter-tag` appears without a following `key=value` token.
1616+ */
1717+export function extractFilterTags(args: string[]): Record<string, string> {
1818+ const tags: Record<string, string> = {};
1919+ for (let i = 0; i < args.length; i++) {
2020+ if (args[i] !== "--filter-tag") continue;
2121+ const kv = args[i + 1];
2222+ if (!kv || !kv.includes("=")) {
2323+ throw new Error(`--filter-tag expects "key=value"`);
2424+ }
2525+ const eq = kv.indexOf("=");
2626+ tags[kv.slice(0, eq)] = kv.slice(eq + 1);
2727+ args.splice(i, 2);
2828+ i--;
2929+ }
3030+ return tags;
3131+}
3232+3333+/**
3434+ * Returns `true` if `sessionTags` contains every key/value pair in `filterTags`
3535+ * (AND match). An empty `filterTags` always matches. A session with no tags
3636+ * only matches an empty `filterTags`.
3737+ */
3838+export function matchesAllTags(
3939+ sessionTags: Record<string, string> | undefined,
4040+ filterTags: Record<string, string>,
4141+): boolean {
4242+ for (const [k, v] of Object.entries(filterTags)) {
4343+ if (sessionTags?.[k] !== v) return false;
4444+ }
4545+ return true;
4646+}
+63-6
src/tui/interactive.ts
···88 cleanupAll, getSession, getSessionDir, type SessionInfo,
99} from "../sessions.ts";
1010import { spawnDaemon, resolveCommand } from "../spawn.ts";
1111+import { matchesAllTags } from "../tags.ts";
1112import {
1213 app, screen, signal, computed, batch,
1314 text, row, spacer, panel, selectable, footer, canvas,
···4445const filterText = signal("");
4546const selectedIndex = signal(0);
4647const currentScreen = signal<"list" | "create" | "remote-create">("list");
4848+4949+/** Tag filter from `--filter-tag key=value`. Filters the list AND auto-applies
5050+ * to any session created from this TUI instance. */
5151+const filterTags = signal<Record<string, string>>({});
47524853// Theme — persisted to ~/.local/state/pty/theme
4954const themeNames = Object.keys(themes);
···101106 status: string;
102107 command?: string;
103108 cwd?: string;
109109+ /** Optional tags reported by the relay (e.g., pty-relay ls --json). Used
110110+ * by the interactive TUI's --filter-tag to filter remote sessions. */
111111+ tags?: Record<string, string>;
104112}
105113106114export interface RelayHost {
···148156 remoteHost?: RelayHost;
149157}
150158151151-const sortedSessions = computed<SessionInfo[]>(() => sortSessions(sessions.get()));
159159+const sortedSessions = computed<SessionInfo[]>(() => {
160160+ const all = sortSessions(sessions.get());
161161+ const required = filterTags.get();
162162+ if (Object.keys(required).length === 0) return all;
163163+ return all.filter((s) => matchesAllTags(s.metadata?.tags, required));
164164+});
152165153166function filterAndSort(filter: string, items: ListItem[]): ListItem[] {
154167 if (!filter) return items;
···235248}
236249237250const filteredGroups = computed<SelectableGroup<ListItem>[]>(() => {
251251+ // When --filter-tag is active, filter remote sessions by tag too. Hosts
252252+ // whose relay reports no tags will simply render no matching sessions.
253253+ const required = filterTags.get();
254254+ const requireTagMatch = Object.keys(required).length > 0;
255255+ const hosts = requireTagMatch
256256+ ? relayHosts.get().map((h) => ({
257257+ ...h,
258258+ sessions: h.sessions.filter((s) => matchesAllTags(s.tags, required)),
259259+ }))
260260+ : relayHosts.get();
238261 return buildFilteredGroups(
239262 filterText.get(),
240263 sortedSessions.get(),
241241- relayHosts.get(),
264264+ hosts,
242265 );
243266});
244267···321344 );
322345323346 const filter = filterText.get();
347347+ const tagFilter = filterTags.get();
348348+ const tagFilterStr = Object.entries(tagFilter).map(([k, v]) => `#${k}=${v}`).join(" ");
324349 const filterLine = filter
325325- ? text(" Filter: " + filter, "primary")
326326- : text(" Filter: (type to filter)", "muted", { dim: true });
350350+ ? text(" Filter: " + filter + (tagFilterStr ? " " + tagFilterStr : ""), "primary")
351351+ : tagFilterStr
352352+ ? text(" Filter: " + tagFilterStr, "primary")
353353+ : text(" Filter: (type to filter)", "muted", { dim: true });
327354328355 const hasRelay = relayHosts.get().length > 0;
329356···824851 const shellCmd = "/bin/sh";
825852 const shellArgs = ["-c", command];
826853854854+ // Auto-apply --filter-tag tags so the new session stays in the layout.
855855+ const tags = filterTags.peek();
856856+ const spawnOpts = { name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir, ...(Object.keys(tags).length > 0 ? { tags } : {}) };
857857+827858 try {
828828- await spawnDaemon({ name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir });
859859+ await spawnDaemon(spawnOpts);
829860 } catch (e: any) {
830861 releaseLock(name);
831862 console.error(e.message);
···899930// Entry point
900931// ============================================================
901932902902-export async function runInteractive(): Promise<void> {
933933+export interface RunInteractiveOptions {
934934+ /** Pre-select the local "+ Create new session..." item on startup. */
935935+ preselectNew?: boolean;
936936+ /** Filter the local list to sessions with all matching tags, and apply
937937+ * them to any session created from this TUI. */
938938+ filterTags?: Record<string, string>;
939939+}
940940+941941+export async function runInteractive(options?: RunInteractiveOptions): Promise<void> {
942942+ if (options?.filterTags && Object.keys(options.filterTags).length > 0) {
943943+ filterTags.set(options.filterTags);
944944+ }
945945+903946 const sessionList = await listSessions();
904947 sessions.set(sessionList);
948948+949949+ if (options?.preselectNew) {
950950+ const groups = filteredGroups.get();
951951+ let idx = 0;
952952+ outer: for (const g of groups) {
953953+ for (const item of g.items) {
954954+ if (item.type === "create") {
955955+ selectedIndex.set(idx);
956956+ break outer;
957957+ }
958958+ idx++;
959959+ }
960960+ }
961961+ }
905962906963 // Fetch relay hosts in the background (non-blocking)
907964 refreshRelayHosts();