From 5ebfc0738fc212aaffc26a793688196cee1be018 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 06:23:21 +0000 Subject: [PATCH] feat: add session slug generator --- src/agents/bash-process-registry.ts | 9 ++ src/agents/bash-tools.exec.ts | 4 +- src/agents/bash-tools.process.ts | 2 +- src/agents/session-slug.test.ts | 26 ++++++ src/agents/session-slug.ts | 133 ++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/agents/session-slug.test.ts create mode 100644 src/agents/session-slug.ts diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 6f7c5750e..009a5d1b9 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -1,4 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { createSessionSlug as createSessionSlugId } from "./session-slug.js"; const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute @@ -65,6 +66,14 @@ const finishedSessions = new Map(); let sweeper: NodeJS.Timeout | null = null; +function isSessionIdTaken(id: string) { + return runningSessions.has(id) || finishedSessions.has(id); +} + +export function createSessionSlug(): string { + return createSessionSlugId(isSessionIdTaken); +} + export function addSession(session: ProcessSession) { runningSessions.set(session.id, session); startSweeper(); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 9be87ab97..d09e14df4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,5 +1,4 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -11,6 +10,7 @@ import { type SessionStdin, addSession, appendOutput, + createSessionSlug, markBackgrounded, markExited, tail, @@ -189,7 +189,7 @@ export function createExecTool( const maxOutput = DEFAULT_MAX_OUTPUT; const startedAt = Date.now(); - const sessionId = randomUUID(); + const sessionId = createSessionSlug(); const warnings: string[] = []; const backgroundRequested = params.background === true; const yieldRequested = typeof params.yieldMs === "number"; diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index fad5e1109..88c0b37ea 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -110,7 +110,7 @@ export function createProcessTool( .sort((a, b) => b.startedAt - a.startedAt) .map((s) => { const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120); - return `${s.sessionId.slice(0, 8)} ${pad( + return `${s.sessionId} ${pad( s.status, 9, )} ${formatDuration(s.runtimeMs)} :: ${label}`; diff --git a/src/agents/session-slug.test.ts b/src/agents/session-slug.test.ts new file mode 100644 index 000000000..22170e5fd --- /dev/null +++ b/src/agents/session-slug.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionSlug } from "./session-slug.js"; + +describe("session slug", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("generates a two-word slug by default", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const slug = createSessionSlug(); + expect(slug).toBe("amber-atlas"); + }); + + it("adds a numeric suffix when the base slug is taken", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const slug = createSessionSlug((id) => id === "amber-atlas"); + expect(slug).toBe("amber-atlas-2"); + }); + + it("falls back to three words when collisions persist", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const slug = createSessionSlug((id) => id === "amber-atlas" || id === "amber-atlas-2"); + expect(slug).toBe("amber-atlas-atlas"); + }); +}); diff --git a/src/agents/session-slug.ts b/src/agents/session-slug.ts new file mode 100644 index 000000000..c3ae926a1 --- /dev/null +++ b/src/agents/session-slug.ts @@ -0,0 +1,133 @@ +const SLUG_ADJECTIVES = [ + "amber", + "briny", + "brisk", + "calm", + "clear", + "cool", + "crisp", + "dawn", + "delta", + "ember", + "faint", + "fast", + "fresh", + "gentle", + "glow", + "good", + "grand", + "keen", + "kind", + "lucky", + "marine", + "mellow", + "mild", + "neat", + "nimble", + "nova", + "oceanic", + "plaid", + "quick", + "quiet", + "rapid", + "salty", + "sharp", + "swift", + "tender", + "tidal", + "tidy", + "tide", + "vivid", + "warm", + "wild", + "young", +]; + +const SLUG_NOUNS = [ + "atlas", + "basil", + "bison", + "bloom", + "breeze", + "canyon", + "cedar", + "claw", + "cloud", + "comet", + "coral", + "cove", + "crest", + "crustacean", + "daisy", + "dune", + "ember", + "falcon", + "fjord", + "forest", + "glade", + "gulf", + "harbor", + "haven", + "kelp", + "lagoon", + "lobster", + "meadow", + "mist", + "nudibranch", + "nexus", + "ocean", + "orbit", + "otter", + "pine", + "prairie", + "reef", + "ridge", + "river", + "rook", + "sable", + "sage", + "seaslug", + "shell", + "shoal", + "shore", + "slug", + "summit", + "tidepool", + "trail", + "valley", + "wharf", + "willow", + "zephyr", +]; + +function randomChoice(values: string[], fallback: string) { + return values[Math.floor(Math.random() * values.length)] ?? fallback; +} + +function createSlugBase(words = 2) { + const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")]; + if (words > 2) parts.push(randomChoice(SLUG_NOUNS, "reef")); + return parts.join("-"); +} + +export function createSessionSlug(isTaken?: (id: string) => boolean): string { + const isIdTaken = isTaken ?? (() => false); + for (let attempt = 0; attempt < 12; attempt += 1) { + const base = createSlugBase(2); + if (!isIdTaken(base)) return base; + for (let i = 2; i <= 12; i += 1) { + const candidate = `${base}-${i}`; + if (!isIdTaken(candidate)) return candidate; + } + } + for (let attempt = 0; attempt < 12; attempt += 1) { + const base = createSlugBase(3); + if (!isIdTaken(base)) return base; + for (let i = 2; i <= 12; i += 1) { + const candidate = `${base}-${i}`; + if (!isIdTaken(candidate)) return candidate; + } + } + const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`; + return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; +}