diff --git a/aesel/bin/design-loop.mjs b/aesel/bin/design-loop.mjs index ecd667373a..1e3e10c038 100755 --- a/aesel/bin/design-loop.mjs +++ b/aesel/bin/design-loop.mjs @@ -80,7 +80,7 @@ async function aeselSessions() { for (const name of names) { try { const marker = JSON.parse(await readFile(join(MARKERS, name), "utf8")); - if (marker.agent_type !== "easel") continue; + if (marker.agent_type !== "easel" && marker.agent_type !== "aesel") continue; out.push({ ...marker, id: marker.session_id || name }); } catch { /* a marker mid-write is not an error, it is a retry */ } } diff --git a/aesel/docs/format.md b/aesel/docs/format.md index 3bc5b634f1..d5f915baae 100644 --- a/aesel/docs/format.md +++ b/aesel/docs/format.md @@ -57,7 +57,7 @@ New clients use consent disclosure version 4. Format readers retain support for ## Company access and retention The endpoint uses AC's server-verified subject for ownership. It has no public -read route. GET requires an explicit subject in `EASEL_TRANSCRIPT_STAFF_SUBS` +read route. GET requires an explicit subject in `AESEL_TRANSCRIPT_STAFF_SUBS` (or the older `EASEL_TRANSCRIPT_STAFF_SUBS`) (comma-separated Auth0 subjects); an email, handle, client-supplied owner field, or unverified staff claim cannot grant access. Staff reads require a named owner and session and return at most 100 records, with an `afterSeq` cursor. DELETE diff --git a/aesel/src/transcript-format.mjs b/aesel/src/transcript-format.mjs index 7051d30a0b..a326c30daa 100644 --- a/aesel/src/transcript-format.mjs +++ b/aesel/src/transcript-format.mjs @@ -1,5 +1,11 @@ // Portable shareable .easel JSON Lines; never a provider-session-state dump. export const TRANSCRIPT_FORMAT = 'aesthetic.easel'; +// Read under both names, written under the old one. TODO(aesel rename): write +// 'aesthetic.aesel' / application 'aesel' once lith has served this reader +// long enough that no deployed server still refuses the new names — i.e. one +// release after the deploy that ships this line. +export const TRANSCRIPT_FORMATS = ['aesthetic.easel', 'aesthetic.aesel']; +export const TRANSCRIPT_APPLICATIONS = ['easel', 'aesel']; // The disclosure people agree to before sharing; a record cites the version it // was agreed under, and no record may cite one that does not exist yet. This // is the one place the number lives — required-sharing.mjs re-exports it. @@ -20,7 +26,7 @@ function id(value) { if (typeof value !== 'string' || !ID.test(value)) throw new function date(value) { if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT/.test(value) || !Number.isFinite(Date.parse(value))) throw new Error('Invalid transcript timestamp'); return new Date(value).toISOString(); } export function validateHeader(value) { object(value,['type','format','version','id','createdAt','metadata','consent','provenance']); - if(value.type!=='session'||value.format!==TRANSCRIPT_FORMAT||value.version!==1)throw new Error('Unsupported .easel format'); + if(value.type!=='session'||!TRANSCRIPT_FORMATS.includes(value.format)||value.version!==1)throw new Error('Unsupported .easel format'); const metadata=object(value.metadata??{},['medium','projectId','title']); if(metadata.medium!==undefined&&!['picture','sound','piece','paper','gameboy'].includes(metadata.medium))throw new Error('Invalid medium'); if(metadata.projectId!==undefined)id(metadata.projectId); @@ -29,9 +35,9 @@ export function validateHeader(value) { if(!['private','company'].includes(consent.sharing))throw new Error('Invalid sharing setting'); if(consent.sharing==='company') {id(consent.id);date(consent.acceptedAt);if(!Number.isInteger(consent.disclosureVersion)||consent.disclosureVersion<1||consent.disclosureVersion>DISCLOSURE_VERSION)throw new Error('Consent disclosure required');} const provenance=object(value.provenance??{application:'easel'},['application','version']); - if(provenance.application!=='easel')throw new Error('Invalid provenance'); + if(!TRANSCRIPT_APPLICATIONS.includes(provenance.application))throw new Error('Invalid provenance'); if(provenance.version!==undefined)text(provenance.version,40,'application version'); - return {type:'session',format:TRANSCRIPT_FORMAT,version:1,id:id(value.id),createdAt:date(value.createdAt),metadata:{...metadata},consent:{...consent},provenance:{...provenance}}; + return {type:'session',format:value.format,version:1,id:id(value.id),createdAt:date(value.createdAt),metadata:{...metadata},consent:{...consent},provenance:{...provenance}}; } export function validateRecord(value) { if(!['message','artifact','event'].includes(value?.type))throw new Error('Invalid transcript record type'); diff --git a/aesel/test/transcripts.test.mjs b/aesel/test/transcripts.test.mjs index bf68a4f12a..51797a6245 100644 --- a/aesel/test/transcripts.test.mjs +++ b/aesel/test/transcripts.test.mjs @@ -112,3 +112,11 @@ test('historical and indefinite-retention disclosures round-trip while unknown v } assert.throws(()=>serializeTranscript({...header,consent:{...header.consent,disclosureVersion:DISCLOSURE_VERSION+1}},[record]),/disclosure/); }); +test('both the easel and the aesel names parse; the journal still writes easel',()=>{ + const renamed={...header,format:'aesthetic.aesel',provenance:{application:'aesel'}}; + const parsed=parseTranscript(serializeTranscript(renamed,[record])); + assert.equal(parsed.header.format,'aesthetic.aesel');assert.equal(parsed.header.provenance.application,'aesel'); + assert.equal(parseTranscript(serializeTranscript(header,[record])).header.format,'aesthetic.easel'); + assert.throws(()=>parseTranscript(serializeTranscript({...header,format:'aesthetic.other'},[record])),/format/); + assert.throws(()=>serializeTranscript({...header,provenance:{application:'other'}},[record]),/provenance/); +}); diff --git a/apple/aesel/Sources/AeselProx.swift b/apple/aesel/Sources/AeselProx.swift index 9f1ebdc213..e709423c98 100644 --- a/apple/aesel/Sources/AeselProx.swift +++ b/apple/aesel/Sources/AeselProx.swift @@ -46,6 +46,8 @@ final class AeselProx { let pid = ProcessInfo.processInfo.processIdentifier // A rock follows this live window across thread switches. No prompt, // source, transcript, credentials, or provider payload is exported. + // agent_type stays "easel" until the fleet's menubars all accept + // "aesel" (see aesel/src/slab-session.mjs). let id = "aesel-native-" + instance let formatter = ISO8601DateFormatter() let piece = String(session.route.split(separator: "/").last ?? "") diff --git a/lith/route-resolution.mjs b/lith/route-resolution.mjs index 578da1cd5e..2a71ad8431 100644 --- a/lith/route-resolution.mjs +++ b/lith/route-resolution.mjs @@ -43,6 +43,14 @@ function resolveFunctionName(fn, rest, functions = {}) { if (ROUTE_ALIASES[fn]) return ROUTE_ALIASES[fn]; + // Aesel was Easel: /api/aesel-* answers from the easel-* function of the + // same name. The easel-* routes stay (installed copies, the App Store build + // and Stripe's webhook all call them); a file named aesel-* would win. + if (fn?.startsWith("aesel-") && !functions[fn]) { + const legacy = `easel-${fn.slice(6)}`; + if (functions[legacy]) return legacy; + } + if (fn === "news" && normalizedRest) { const firstSegment = normalizedRest.split("/")[0]; return NEWS_API_ROUTES.has(firstSegment) ? "news-api" : "news"; diff --git a/slab/bin/claude-notify.sh b/slab/bin/claude-notify.sh index 9770b38b3d..2d7ef0dcac 100755 --- a/slab/bin/claude-notify.sh +++ b/slab/bin/claude-notify.sh @@ -9,9 +9,10 @@ # 3. Speak "help me" with a short fade tail (claude-help.py). # The pause flag is cleared by the next UserPromptSubmit (user responded) # or by Stop (work ended). Does NOT sleep the Mac. -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-ping-repeat.sh b/slab/bin/claude-ping-repeat.sh index c34a0ab03f..6346790e47 100755 --- a/slab/bin/claude-ping-repeat.sh +++ b/slab/bin/claude-ping-repeat.sh @@ -1,9 +1,10 @@ #!/bin/bash # Random high-pitched major-chord ping every INTERVAL seconds while lid is CLOSED. # Exits automatically once the lid is reopened. -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh index b042beacb2..5b1c53053c 100755 --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -2,9 +2,10 @@ # UserPromptSubmit hook: log prompt, mark session as active, set awake. # Writes JSON metadata to active-prompts/ so the menubar can # render per-session status (subject, cwd, tty, claude pid). -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-sleep-schedule.sh b/slab/bin/claude-sleep-schedule.sh index ac0092e3ad..f3ad6ced24 100755 --- a/slab/bin/claude-sleep-schedule.sh +++ b/slab/bin/claude-sleep-schedule.sh @@ -1,9 +1,10 @@ #!/bin/bash # Sleeps the Mac after DELAY seconds if the lid is still closed at that point. # Plays the sleep tone first for a dreamy auditory cue. -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-stop.sh b/slab/bin/claude-stop.sh index 7fef747c8d..471983bd94 100755 --- a/slab/bin/claude-stop.sh +++ b/slab/bin/claude-stop.sh @@ -14,9 +14,10 @@ # others = 0 → "all done" chime (lid open) OR TTS "i'm tired" with fade-out # tail → `pmset sleepnow` (lid closed: stops ambient first, so # the transition to sleep is a gentle dissolve instead of a cut). -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-subagent-stop.sh b/slab/bin/claude-subagent-stop.sh index c895694efc..9b15404358 100755 --- a/slab/bin/claude-subagent-stop.sh +++ b/slab/bin/claude-subagent-stop.sh @@ -3,9 +3,10 @@ # ping. FIFO-per-session removal keeps each session's count accurate even when # subagents finish out of order. Falls back to the globally-oldest marker if # the session subdir is empty, so counts can never leak upward. -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} diff --git a/slab/bin/claude-tool-heartbeat.sh b/slab/bin/claude-tool-heartbeat.sh index 437a3d41d5..ba4f3d55c1 100755 --- a/slab/bin/claude-tool-heartbeat.sh +++ b/slab/bin/claude-tool-heartbeat.sh @@ -10,9 +10,10 @@ # marker mtime ages). See ClaudeSession.swift isInterrupted(). # # Usage: claude-tool-heartbeat.sh (hook JSON on stdin) -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u MODE="${1:-pre}" SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} diff --git a/slab/bin/claude-tool-pre.sh b/slab/bin/claude-tool-pre.sh index 617332b3bb..0cc10d7354 100755 --- a/slab/bin/claude-tool-pre.sh +++ b/slab/bin/claude-tool-pre.sh @@ -4,9 +4,10 @@ # THAT session's polygon edge. SubagentStop removes the session's oldest marker. # (Workflow-tool agents don't fire this hook — the menubar counts those live # from the workflow transcript dir instead.) -# A Claude that Easel is driving is that session's engine, not a rock of its -# own; Easel keeps the marker, so this hook has nothing to say. -[[ -n "${EASEL_SESSION_ID:-}" ]] && exit 0 +# A Claude that Aesel is driving is that session's engine, not a rock of its +# own; Aesel keeps the marker, so this hook has nothing to say. (EASEL_ is the +# name an Aesel from before the rename exports.) +[[ -n "${AESEL_SESSION_ID:-}${EASEL_SESSION_ID:-}" ]] && exit 0 set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SUBAGENT_DIR="$SLAB_HOME/state/active-subagents" diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index 3510b16527..bbe1da63e3 100755 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -45,7 +45,11 @@ const SLAB_HOME = process.env.SLAB_HOME || join(homedir(), ".local", "share", "s const MARKER_DIRS = [join(SLAB_HOME, "state", "active-prompts"), join(SLAB_HOME, "state", "awaiting-prompts")]; const shellQuote = (s) => `'${String(s).replaceAll("'", `'"'"'`)}'`; -const isCodexBacked = (agent) => agent === "codex" || agent === "easel"; +// Aesel was Easel. Either spelling names the same interface, and inside prox it +// is "easel" until every writer has flipped (see aesel/src/slab-session.mjs): +// a menubar that predates the rename launches and lists only "easel". +const canonicalAgent = (agent) => (agent === "aesel" ? "easel" : agent); +const isCodexBacked = (agent) => agent === "codex" || canonicalAgent(agent) === "easel"; async function findFile(root, suffix) { let entries; @@ -63,7 +67,7 @@ async function findFile(root, suffix) { async function transcriptFor(rock, marker) { if (marker?.transcript_path) return marker.transcript_path; - const agent = marker?.agent_type || rock.agentType || "claude"; + const agent = canonicalAgent(marker?.agent_type || rock.agentType || "claude"); const providerId = marker?.provider_session_id || marker?.codex_session_id || rock.id; if (isCodexBacked(agent)) { return findFile(join(homedir(), ".codex", "sessions"), `${providerId}.jsonl`); @@ -112,7 +116,7 @@ async function allRocks() { const rows = []; for (const led of await allLedgers()) { for (const e of led.entries || []) { - rows.push({ ...e, host: e.host || led.host, ip: led.ip, self: led.self }); + rows.push({ ...e, agentType: canonicalAgent(e.agentType), host: e.host || led.host, ip: led.ip, self: led.self }); } } return rows; @@ -132,7 +136,7 @@ function age(ms) { function resolve(rocks, handle) { if (!handle) return rocks; const h = handle.trim().toLowerCase(); - if (h.startsWith("prox:easel:") || h.startsWith("easel:")) { + if (/^(prox:)?(easel|aesel):/.test(h)) { const parts = h.replace(/^prox:/, "").split(":").slice(1); if (parts.length < 1 || parts.length > 2 || parts.some(p => !p)) return []; const [host, name] = parts.length === 2 ? parts : [null, parts[0]]; @@ -224,7 +228,7 @@ async function toolList({ host, status, kind, agent, all } = {}) { if (host) rocks = rocks.filter((r) => r.host.toLowerCase() === host.toLowerCase()); if (status) rocks = rocks.filter((r) => r.status === status); if (kind) rocks = rocks.filter((r) => r.kind === kind); - if (agent) rocks = rocks.filter((r) => (r.agentType || "claude").toLowerCase() === agent.toLowerCase()); + if (agent) rocks = rocks.filter((r) => (r.agentType || "claude").toLowerCase() === canonicalAgent(agent.toLowerCase())); let hidden = 0; if (!all && !status) { const now = Date.now(); @@ -385,7 +389,7 @@ async function toolDump({ handle, destination } = {}) { const transcript = await transcriptFor(r, marker); if (!transcript) throw new Error(`could not locate the persisted transcript for ${r.host}:${r.name}.`); - const agent = marker.agent_type || r.agentType || "claude"; + const agent = canonicalAgent(marker.agent_type || r.agentType || "claude"); const providerId = marker.provider_session_id || marker.codex_session_id || r.id; const base = destination ? String(destination) : join(homedir(), "Desktop"); const safeName = `${r.host}-${r.name}`.replace(/[^a-zA-Z0-9._-]+/g, "-"); @@ -449,9 +453,9 @@ exec claude --resume ${shellQuote(r.id)} async function toolLaunch({ host, agent, cwd, prompt = "", by, loopboyContact = "" }) { const wanted = String(host || "").trim().toLowerCase().replace(/\.local$/, ""); if (!wanted) throw new Error("`host` is required (for example, poorslice)."); - const agentName = String(agent || "").trim().toLowerCase(); + const agentName = canonicalAgent(String(agent || "").trim().toLowerCase()); if (!new Set(["claude", "codex", "easel"]).has(agentName)) { - throw new Error("`agent` must be `claude`, `codex`, or `easel`."); + throw new Error("`agent` must be `claude`, `codex`, or `aesel` (or `easel`)."); } if (String(prompt).length > 4000) throw new Error("`prompt` exceeds 4000 characters."); const contactKey = String(loopboyContact || "").trim().toLowerCase(); @@ -691,7 +695,7 @@ const TOOLS = [ host: { type: "string", description: "Only rocks on this machine (e.g. neo, blueberry, panda)." }, status: { type: "string", description: "Filter by status: working | awaiting | complete | rendering | blank | interrupted. Disables the stale-rock hiding." }, kind: { type: "string", description: "Filter by kind: session | agent." }, - agent: { type: "string", description: "Filter by owning interface: claude | codex | easel." }, + agent: { type: "string", description: "Filter by owning interface: claude | codex | aesel (easel is the same)." }, all: { type: "boolean", description: "Include finished rocks idle for more than 24h (hidden by default)." }, }, }, @@ -755,7 +759,7 @@ const TOOLS = [ inputSchema: { type: "object", properties: { - handle: { type: "string", description: "A host:name, session id, or prox:easel:name resolving to exactly one rock." }, + handle: { type: "string", description: "A host:name, session id, or prox:aesel:name (prox:easel:name) resolving to exactly one rock." }, prompt: { type: "string", description: "Continuation prompt, at most 1000 characters." }, by: { type: "string", description: "Optional caller label recorded by the target." }, }, @@ -795,7 +799,7 @@ const TOOLS = [ type: "object", properties: { host: { type: "string", description: "Target Slab hostname, for example poorslice." }, - agent: { type: "string", enum: ["claude", "codex", "easel"], description: "Interface to launch." }, + agent: { type: "string", enum: ["claude", "codex", "aesel", "easel"], description: "Interface to launch (aesel and easel are the same)." }, cwd: { type: "string", description: "Optional absolute directory on the target. Defaults to its aesthetic-computer checkout and must stay under its home folder." }, prompt: { type: "string", description: "Optional initial prompt, at most 4000 characters. Omit to open an idle TUI." }, by: { type: "string", description: "Optional caller label recorded by the target." }, diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 7d964d96bc..879b4acd9b 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -80,11 +80,16 @@ struct ClaudeSession { var remoteHost: String = "" /// Which interface owns this session — "claude" (default), "codex", or - /// "easel". + /// "easel" (Aesel; see `canonicalAgent`). /// Read from the marker's `agent_type`; drives per-agent labels/tooltips /// in the menu. The state/color engine is agent-agnostic, so this is /// display-only. var agentType: String = "claude" + + /// Aesel was Easel. A writer that has flipped says "aesel"; inside Slab + /// the interface stays "easel", so every comparison here holds for both. + /// Every marker and ledger read passes through this. + static func canonicalAgent(_ type: String) -> String { type == "aesel" ? "easel" : type } var hostApp: String = "" var hostPid: Int = 0 var hostWindowID: Int = 0 @@ -386,7 +391,7 @@ enum ClaudeSessionReader { // Hook-backed clients still derive most state from the side-channel // markers. Easel receives direct app-server lifecycle events // and can publish exact idle/interrupted state itself. - let agentType = (obj["agent_type"] as? String) ?? "claude" + let agentType = ClaudeSession.canonicalAgent((obj["agent_type"] as? String) ?? "claude") let parsedState: ClaudeSession.State = { switch obj["state"] as? String { case "blank": return .blank diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index 90dba7cf22..6854eb8f02 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -163,9 +163,9 @@ final class LedgerStore { /// home folder. private static func launchPrompt(_ body: [String: Any]) -> [String: Any] { let requestedAgent = ((body["agent"] as? String) ?? "").lowercased() - let agent = requestedAgent == "aesthetic" ? "easel" : requestedAgent + let agent = requestedAgent == "aesthetic" ? "easel" : ClaudeSession.canonicalAgent(requestedAgent) guard agent == "claude" || agent == "codex" || agent == "easel" else { - return ["ok": false, "error": "agent must be claude, codex, or easel"] + return ["ok": false, "error": "agent must be claude, codex, or aesel"] } let prompt = (body["prompt"] as? String) ?? "" @@ -208,7 +208,11 @@ final class LedgerStore { if agent == "codex" { binary = "\(Paths.slabBin)/codex-slab" } else if agent == "easel" { - binary = "\(Paths.home)/.local/bin/easel" + // `aes` is the terminal Aesel under its new name (`aesel` itself + // opens the native app and takes no flags); installs that predate + // it have only `easel`. + let aes = "\(Paths.home)/.local/bin/aes" + binary = fm.isExecutableFile(atPath: aes) ? aes : "\(Paths.home)/.local/bin/easel" } else { binary = "\(Paths.home)/.local/bin/claude" } @@ -421,7 +425,7 @@ final class LedgerStore { (try? JSONDecoder().decode(Ledger.self, from: Data(contentsOf: URL(fileURLWithPath: Self.peersDir + "/" + file))))?.entries ?? [] } - var used = Set(peers.filter { $0.proxNamespace == "easel" }.compactMap(\.proxName)) + var used = Set(peers.filter { ClaudeSession.canonicalAgent($0.proxNamespace ?? "") == "easel" }.compactMap(\.proxName)) let easelIndices = entries.indices.filter { entries[$0].agentType == "easel" }.sorted { entries[$0].id < entries[$1].id } @@ -501,7 +505,7 @@ final class LedgerStore { updated: mtime.timeIntervalSince1970 * 1000, started: (obj["started"] as? Double), memoir: (obj["memoir"] as? String), - agentType: (obj["agent_type"] as? String), + agentType: (obj["agent_type"] as? String).map(ClaudeSession.canonicalAgent), platformTarget: (obj["platform_target"] as? String), loopboyContact: (obj["loopboy_contact"] as? String), scanURL: (obj["scan_url"] as? String))) diff --git a/slab/menubar-swift/Sources/SlabMenubar/NativeAeselSession.swift b/slab/menubar-swift/Sources/SlabMenubar/NativeAeselSession.swift index 53053b4dc0..675c63d657 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/NativeAeselSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/NativeAeselSession.swift @@ -16,7 +16,8 @@ enum NativeAeselSession { let layout = object["layout"] as? [String: Any], let id = marker["session_id"] as? String, id.hasPrefix("aesel-native-"), UUID(uuidString: String(id.dropFirst("aesel-native-".count))) != nil, - marker["agent_type"] as? String == "easel", marker["host_app"] as? String == bundleID, + ["easel", "aesel"].contains(marker["agent_type"] as? String ?? ""), // Aesel was Easel + marker["host_app"] as? String == bundleID, let pid = marker["agent_pid"] as? Int, pid > 0, pid <= Int(Int32.max), marker["host_pid"] as? Int == pid, ownsPID(pid), let window = marker["host_window_id"] as? Int, window > 0, diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift index 4ff968c3cd..9e9e1f6aa6 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -798,7 +798,12 @@ final class PromptPreview { guard !channel.isEmpty, !revision.isEmpty, cwd.hasPrefix("/"), Date().timeIntervalSince(framePollAt) >= 0.25, frameCaptureID == nil else { return } framePollAt = Date() - let base = URL(fileURLWithPath: cwd).appendingPathComponent(".easel", isDirectory: true) + // .aesel, or the .easel a workspace from before the rename keeps + // (aesel/src/paths.mjs chooses the same way). + let root = URL(fileURLWithPath: cwd) + let aesel = root.appendingPathComponent(".aesel", isDirectory: true) + let base = FileManager.default.fileExists(atPath: aesel.path) + ? aesel : root.appendingPathComponent(".easel", isDirectory: true) let requests = base.appendingPathComponent("frame-requests", isDirectory: true) let responses = base.appendingPathComponent("frame-responses", isDirectory: true) guard Self.frameDirectoryIsSafe(base), Self.frameDirectoryIsSafe(requests), diff --git a/slab/menubar-swift/tests/native-aesel-session.sh b/slab/menubar-swift/tests/native-aesel-session.sh index 909726240f..5e1be66861 100644 --- a/slab/menubar-swift/tests/native-aesel-session.sh +++ b/slab/menubar-swift/tests/native-aesel-session.sh @@ -39,6 +39,11 @@ for state in ["blank", "working", "complete", "awaiting", "interrupted"] { } marker["state"] = "unknown" assert(decode() == nil) +marker["state"] = "working" +marker["agent_type"] = "aesel" +assert(decode() != nil) +marker["agent_type"] = "codex" +assert(decode() == nil) print("Native aesel: identity, status, stale/dead process, geometry and export boundary checks passed") SWIFT swift "$probeDir/check.swift" diff --git a/slab/test/prox-mcp.test.mjs b/slab/test/prox-mcp.test.mjs index 48512385da..25749d3c0d 100644 --- a/slab/test/prox-mcp.test.mjs +++ b/slab/test/prox-mcp.test.mjs @@ -366,3 +366,18 @@ test("Easel namespace is exact, fleet ambiguity is preserved, and local stays lo assert.match(await callProx(home,"prox_find",{handle:"local:two"}),/no rock resolves/); assert.match(await callProx(home,"prox_poke",{handle:"prox:easel:bugo"}),/ambiguous/); }); + +test("aesel and easel name the same agent type and namespace", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-aesel-test-")); + const dir = join(home, ".config", "slab", "ledger"); + await mkdir(join(dir, "peers"), { recursive: true }); + const entry = (host, agentType, id) => ({ id, host, name: "old-piece", proxName: "bugo", proxNamespace: agentType, agentType, kind: "session", status: "complete", updated: Date.now() }); + // One writer that has flipped, one that has not. + await writeFile(join(dir, "local.json"), JSON.stringify({host:"blueberry",entries:[entry("blueberry","aesel","one")]})); + await writeFile(join(dir, "peers", "neo.json"), JSON.stringify({host:"neo",entries:[entry("neo","easel","two")]})); + assert.match(await callProx(home,"prox_find",{handle:"prox:aesel:bugo"}),/2 match/); + assert.match(await callProx(home,"prox_find",{handle:"prox:easel:bugo"}),/2 match/); + assert.match(await callProx(home,"prox_find",{handle:"aesel:blueberry:bugo"}),/1 match/); + const listed = await callProx(home,"prox_list",{agent:"aesel",all:true}); + assert.match(listed,/blueberry/); assert.match(listed,/neo/); +}); diff --git a/system/backend/easel-transcripts.mjs b/system/backend/easel-transcripts.mjs index 5ab5259cd4..97a474183a 100644 --- a/system/backend/easel-transcripts.mjs +++ b/system/backend/easel-transcripts.mjs @@ -14,7 +14,7 @@ export async function ensureTranscriptIndexes(db) { await collection.createIndex({expiresAt:1},{name:'easel_transcript_expiration_marker'}); await collection.createIndex({owner:1,sessionId:1,'record.seq':1},{name:'easel_transcript_owner_session',unique:true,partialFilterExpression:{'record.seq':{$gt:0}}}); } -export function createTranscriptHandler({authorize,connect,staffSubs=()=>process.env.EASEL_TRANSCRIPT_STAFF_SUBS||'',now=()=>new Date()}={}) { +export function createTranscriptHandler({authorize,connect,staffSubs=()=>process.env.AESEL_TRANSCRIPT_STAFF_SUBS||process.env.EASEL_TRANSCRIPT_STAFF_SUBS||'',now=()=>new Date()}={}) { const initialized=new WeakSet(); return async event=>{ if(!['POST','GET','DELETE'].includes(event.httpMethod))return reply(405,{error:'POST, GET or DELETE required'}); diff --git a/tests/lith-route-resolution.test.mjs b/tests/lith-route-resolution.test.mjs index 935596e765..35362b2a8f 100644 --- a/tests/lith-route-resolution.test.mjs +++ b/tests/lith-route-resolution.test.mjs @@ -14,6 +14,12 @@ function run() { assert.equal(resolveFunctionName("chat", "messages"), "chat-messages"); assert.equal(resolveFunctionName("verify-password"), "verify-builds-password"); assert.equal(resolveFunctionName("ff1", "proxy/status", { "ff1-proxy": true }), "ff1-proxy"); + const easel = { "easel-inference": true, "easel-checkout": true }; + assert.equal(resolveFunctionName("aesel-inference", undefined, easel), "easel-inference"); + assert.equal(resolveFunctionName("aesel-checkout", undefined, easel), "easel-checkout"); + assert.equal(resolveFunctionName("easel-inference", undefined, easel), "easel-inference"); + assert.equal(resolveFunctionName("aesel-nothing", undefined, easel), "aesel-nothing"); + assert.equal(resolveFunctionName("aesel-live", undefined, { "aesel-live": true, "easel-live": true }), "aesel-live"); console.log("✅ lith route resolution tests passed"); }