From 3133c7c84e8a23a0fff66de0b6de5ebd59149917 Mon Sep 17 00:00:00 2001 From: Azade Date: Thu, 8 Jan 2026 23:17:08 +0000 Subject: [PATCH 1/5] feat(sessions): expose label in sessions.list and support label lookup in sessions_send - Add `label` field to session entries and expose it in `sessions.list` - Display label column in the web UI sessions table - Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey - `sessions.patch`: Accept and store `label` field - `sessions.list`: Return `label` in session entries - `sessions_spawn`: Pass label through to registry and announce flow - `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided - `agent` method: Accept `label` and `spawnedBy` params (stored in session entry) - Add `label` column to sessions table in web UI - Changed session store writes to merge with existing entry (`{ ...existing, ...new }`) to preserve fields like `label` that might be set separately We attempted to implement label persistence "properly" by passing the label through the `agent` call and storing it during session initialization. However, the auto-reply flow has multiple write points that overwrite the session entry, and making all of them merge-aware proved unreliable. The working solution patches the label in the `finally` block of `runSubagentAnnounceFlow`, after all other session writes complete. This is a workaround but robust - the patch happens at the very end, just before potential cleanup. A future refactor could make session writes consistently merge-based, which would allow the cleaner approach of setting label at spawn time. ```typescript // Spawn with label sessions_spawn({ task: "...", label: "my-worker" }) // Later, find by label sessions_send({ label: "my-worker", message: "continue..." }) // Or use sessions_list to see labels sessions_list() // includes label field in response ``` --- src/agents/subagent-announce.ts | 13 ++++++++ src/agents/subagent-registry.ts | 6 ++++ src/agents/tools/sessions-list-tool.ts | 2 ++ src/agents/tools/sessions-send-tool.ts | 37 +++++++++++++++++++--- src/agents/tools/sessions-spawn-tool.ts | 15 +++------ src/auto-reply/reply/agent-runner.ts | 6 ++-- src/auto-reply/reply/directive-handling.ts | 4 +-- src/auto-reply/reply/model-selection.ts | 4 +-- src/auto-reply/reply/session-updates.ts | 4 +-- src/auto-reply/reply/session.ts | 2 +- src/config/sessions.ts | 1 + src/gateway/protocol/schema.ts | 3 ++ src/gateway/server-bridge.ts | 20 ++++++++++++ src/gateway/server-methods/agent.ts | 6 ++++ src/gateway/server-methods/sessions.ts | 19 +++++++++++ src/gateway/server.sessions.test.ts | 15 +++++++++ src/gateway/session-utils.ts | 2 ++ src/tui/gateway-chat.ts | 1 + ui/src/ui/types.ts | 1 + ui/src/ui/views/sessions.ts | 8 ++++- 20 files changed, 142 insertions(+), 27 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 3240f339c..cc31af1fd 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: { waitForCompletion?: boolean; startedAt?: number; endedAt?: number; + label?: string; }) { try { let reply = params.roundOneReply; @@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: { } catch { // Best-effort follow-ups; ignore failures to avoid breaking the caller response. } finally { + // Patch label after all writes complete + if (params.label) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: params.childSessionKey, label: params.label }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort + } + } if (params.cleanup === "delete") { try { await callGateway({ diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index cfd022145..e5ce8360c 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -11,6 +11,7 @@ export type SubagentRunRecord = { requesterDisplayKey: string; task: string; cleanup: "delete" | "keep"; + label?: string; createdAt: number; startedAt?: number; endedAt?: number; @@ -83,6 +84,7 @@ function ensureListener() { ? (evt.data.endedAt as number) : Date.now(); entry.endedAt = endedAt; + if (!beginSubagentAnnounce(evt.runId)) { if (entry.cleanup === "delete") { subagentRuns.delete(evt.runId); @@ -101,6 +103,7 @@ function ensureListener() { waitForCompletion: false, startedAt: entry.startedAt, endedAt: entry.endedAt, + label: entry.label, }); if (entry.cleanup === "delete") { subagentRuns.delete(evt.runId); @@ -124,6 +127,7 @@ export function registerSubagentRun(params: { requesterDisplayKey: string; task: string; cleanup: "delete" | "keep"; + label?: string; }) { const now = Date.now(); const archiveAfterMs = resolveArchiveAfterMs(); @@ -136,6 +140,7 @@ export function registerSubagentRun(params: { requesterDisplayKey: params.requesterDisplayKey, task: params.task, cleanup: params.cleanup, + label: params.label, createdAt: now, startedAt: now, archiveAtMs, @@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) { waitForCompletion: false, startedAt: entry.startedAt, endedAt: entry.endedAt, + label: entry.label, }); if (entry.cleanup === "delete") { subagentRuns.delete(runId); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 4afc708a5..87108fad7 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -25,6 +25,7 @@ type SessionListRow = { key: string; kind: SessionKind; provider: string; + label?: string; displayName?: string; updatedAt?: number | null; sessionId?: string; @@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: { key: displayKey, kind, provider: derivedProvider, + label: typeof entry.label === "string" ? entry.label : undefined, displayName: typeof entry.displayName === "string" ? entry.displayName diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index b3711ffef..5dbf5893d 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -30,7 +30,8 @@ import { } from "./sessions-send-helpers.js"; const SessionsSendToolSchema = Type.Object({ - sessionKey: Type.String(), + sessionKey: Type.Optional(Type.String()), + label: Type.Optional(Type.String()), message: Type.String(), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), }); @@ -43,15 +44,41 @@ export function createSessionsSendTool(opts?: { return { label: "Session Send", name: "sessions_send", - description: "Send a message into another session.", + description: + "Send a message into another session. Use sessionKey or label to identify the target.", parameters: SessionsSendToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const sessionKey = readStringParam(params, "sessionKey", { - required: true, - }); + let sessionKey = readStringParam(params, "sessionKey"); + const labelParam = readStringParam(params, "label"); const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); + + // Lookup by label if sessionKey not provided + if (!sessionKey && labelParam) { + const listResult = (await callGateway({ + method: "sessions.list", + params: { activeMinutes: 1440 }, // Last 24h + timeoutMs: 10_000, + })) as { sessions?: Array<{ key: string; label?: string }> }; + const match = listResult.sessions?.find( + (s) => s.label === labelParam, + ); + if (!match) { + return jsonResult({ + status: "error", + error: `No session found with label: ${labelParam}`, + }); + } + sessionKey = match.key; + } + + if (!sessionKey) { + return jsonResult({ + status: "error", + error: "Either sessionKey or label is required", + }); + } const { mainKey, alias } = resolveMainSessionAlias(cfg); const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 2379bfafd..e6260a38a 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: { } } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; - if (opts?.sandboxed === true) { - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, spawnedBy: requesterInternalKey }, - timeoutMs: 10_000, - }); - } catch { - // best-effort; scoping relies on this metadata but spawning still works without it - } - } + const shouldPatchSpawnedBy = opts?.sandboxed === true; if (model) { try { await callGateway({ @@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: { lane: "subagent", extraSystemPrompt: childSystemPrompt, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, + label: label || undefined, + spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined, }, timeoutMs: 10_000, })) as { runId?: string }; @@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: { requesterDisplayKey, task, cleanup, + label: label || undefined, }); return jsonResult({ diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 48bfc7cbc..dd1c8a2cc 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -271,7 +271,7 @@ export async function runReplyAgent(params: { if (steered && !shouldFollowup) { if (sessionEntry && sessionStore && sessionKey) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -285,7 +285,7 @@ export async function runReplyAgent(params: { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); if (sessionEntry && sessionStore && sessionKey) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -674,7 +674,7 @@ export async function runReplyAgent(params: { ) { sessionEntry.groupActivationNeedsSystemIntro = false; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index ce5248966..adf52410e 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -880,7 +880,7 @@ export async function handleDirectiveOnly(params: { } } sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -1099,7 +1099,7 @@ export async function persistInlineDirectives(params: { } if (updated) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 37b290309..fa759be0a 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -95,7 +95,7 @@ export async function createModelSelectionState(params: { delete sessionEntry.providerOverride; delete sessionEntry.modelOverride; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -129,7 +129,7 @@ export async function createModelSelectionState(params: { if (!profile || profile.provider !== provider) { delete sessionEntry.authProfileOverride; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index a09d441c6..b437fb132 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: { systemSent: true, skillsSnapshot: skillSnapshot, }; - sessionStore[sessionKey] = nextEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = nextEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 5cf3bd8cc..199afe7f6 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -264,7 +264,7 @@ export async function initSessionState(params: { ctx.MessageThreadId, ); } - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; await saveSessionStore(storePath, sessionStore); const sessionCtx: TemplateContext = { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 72b1eae5d..29670cb95 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -113,6 +113,7 @@ export type SessionEntry = { contextTokens?: number; compactionCount?: number; claudeCliSessionId?: string; + label?: string; displayName?: string; provider?: string; subject?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 4e2e98700..e5f5a8d2f 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -225,6 +225,8 @@ export const AgentParamsSchema = Type.Object( lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, + label: Type.Optional(Type.String()), + spawnedBy: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -322,6 +324,7 @@ export const SessionsListParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, + label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 44cbde1e8..8a34b8088 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -397,6 +397,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } + if ("label" in p) { + const raw = p.label; + if (raw === null) { + delete next.label; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid label: empty", + }, + }; + } + next.label = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -628,6 +647,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, + label: entry?.label, displayName: entry?.displayName, chatType: entry?.chatType, provider: entry?.provider, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 790929fcc..a96e67b3a 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -52,6 +52,8 @@ export const agentHandlers: GatewayRequestHandlers = { extraSystemPrompt?: string; idempotencyKey: string; timeout?: number; + label?: string; + spawnedBy?: string; }; const idem = request.idempotencyKey; const cached = context.dedupe.get(`agent:${idem}`); @@ -78,6 +80,8 @@ export const agentHandlers: GatewayRequestHandlers = { cfgForAgent = cfg; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); + const labelValue = request.label?.trim() || entry?.label; + const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy; const nextEntry: SessionEntry = { sessionId, updatedAt: now, @@ -91,6 +95,8 @@ export const agentHandlers: GatewayRequestHandlers = { lastTo: entry?.lastTo, modelOverride: entry?.modelOverride, providerOverride: entry?.providerOverride, + label: labelValue, + spawnedBy: spawnedByValue, }; sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index fb265c891..137fb3bed 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -169,6 +169,24 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("label" in p) { + const raw = p.label; + if (raw === null) { + delete next.label; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid label: empty"), + ); + return; + } + next.label = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -422,6 +440,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, + label: entry?.label, lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 675f5213a..07ebe3a5f 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -148,12 +148,23 @@ describe("gateway server sessions", () => { expect(sendPolicyPatched.ok).toBe(true); expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); + const labelPatched = await rpcReq<{ + ok: true; + entry: { label?: string }; + }>(ws, "sessions.patch", { + key: "agent:main:subagent:one", + label: "Briefing", + }); + expect(labelPatched.ok).toBe(true); + expect(labelPatched.payload?.entry.label).toBe("Briefing"); + const list2 = await rpcReq<{ sessions: Array<{ key: string; thinkingLevel?: string; verboseLevel?: string; sendPolicy?: string; + label?: string; }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); @@ -163,6 +174,10 @@ describe("gateway server sessions", () => { expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.sendPolicy).toBe("deny"); + const subagent = list2.payload?.sessions.find( + (s) => s.key === "agent:main:subagent:one", + ); + expect(subagent?.label).toBe("Briefing"); const spawnedOnly = await rpcReq<{ sessions: Array<{ key: string }>; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 9042f763d..ff8c4fbf9 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -34,6 +34,7 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + label?: string; displayName?: string; provider?: string; subject?: string; @@ -485,6 +486,7 @@ export function listSessionsFromStore(params: { return { key, kind: classifySessionKey(key, entry), + label: entry?.label, displayName, provider, subject, diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index bd8afd21c..5b52568bd 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -49,6 +49,7 @@ export type GatewaySessionList = { totalTokens?: number | null; responseUsage?: "on" | "off"; modelProvider?: string; + label?: string; displayName?: string; provider?: string; room?: string; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 96d1c30a6..c65fc92c1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -215,6 +215,7 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + label?: string; displayName?: string; surface?: string; subject?: string; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 008285ab9..1d655b1a2 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -117,6 +117,7 @@ export function renderSessions(props: SessionsProps) {
Key
+
Label
Kind
Updated
Tokens
@@ -132,7 +133,11 @@ export function renderSessions(props: SessionsProps) { `; } -function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsProps["onPatch"]) { +function renderRow( + row: GatewaySessionRow, + basePath: string, + onPatch: SessionsProps["onPatch"], +) { const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; const thinking = row.thinkingLevel ?? ""; const verbose = row.verboseLevel ?? ""; @@ -148,6 +153,7 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr
${canLink ? html`${displayName}` : displayName}
+
${row.label ?? ""}
${row.kind}
${updated}
${formatSessionTokens(row)}
From e24e0cf364f5297159dfd20227fb2f95e13c2bc9 Mon Sep 17 00:00:00 2001 From: Azade Date: Thu, 8 Jan 2026 23:32:42 +0000 Subject: [PATCH 2/5] test(sessions): add tests for sessions_send label lookup - Test finding session by label - Test error when label not found - Test error when neither sessionKey nor label provided --- src/gateway/server.sessions-send.test.ts | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 73ea13d8b..3972ef6ac 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -101,3 +101,147 @@ describe("sessions_send gateway loopback", () => { } }); }); + +describe("sessions_send label lookup", () => { + it("finds session by label and sends message", async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + const spy = vi.mocked(agentCommand); + spy.mockImplementation(async (opts) => { + const params = opts as { + sessionId?: string; + runId?: string; + extraSystemPrompt?: string; + }; + const sessionId = params.sessionId ?? "test-labeled"; + const runId = params.runId ?? sessionId; + const sessionFile = resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(sessionFile), { recursive: true }); + + const startedAt = Date.now(); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt }, + }); + + const text = "labeled response"; + const message = { + role: "assistant", + content: [{ type: "text", text }], + }; + await fs.appendFile( + sessionFile, + `${JSON.stringify({ message })}\n`, + "utf8", + ); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt, endedAt: Date.now() }, + }); + }); + + try { + // First, create a session with a label via sessions.patch + const { callGateway } = await import("./call.js"); + await callGateway({ + method: "sessions.patch", + params: { key: "test-labeled-session", label: "my-test-worker" }, + timeoutMs: 5000, + }); + + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + // Send using label instead of sessionKey + const result = await tool.execute("call-by-label", { + label: "my-test-worker", + message: "hello labeled session", + timeoutSeconds: 5, + }); + const details = result.details as { + status?: string; + reply?: string; + sessionKey?: string; + }; + expect(details.status).toBe("ok"); + expect(details.reply).toBe("labeled response"); + expect(details.sessionKey).toBe("test-labeled-session"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }); + + it("returns error when label not found", async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + + try { + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + const result = await tool.execute("call-missing-label", { + label: "nonexistent-label", + message: "hello", + timeoutSeconds: 5, + }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toContain("No session found with label"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }); + + it("returns error when neither sessionKey nor label provided", async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + + try { + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + const result = await tool.execute("call-no-key", { + message: "hello", + timeoutSeconds: 5, + }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toContain("Either sessionKey or label is required"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }); +}); From 56e77f68432e82d053f82f10c16ef0a21be7fa6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:01:49 +0100 Subject: [PATCH 3/5] fix: sessions label lookup and persistence (#570) (thanks @azade-c) --- CHANGELOG.md | 1 + src/agents/subagent-registry.ts | 2 +- src/agents/tools/sessions-send-tool.ts | 173 ++++++++++++++------- src/auto-reply/reply/agent-runner.ts | 6 +- src/auto-reply/reply/directive-handling.ts | 4 +- src/auto-reply/reply/model-selection.ts | 4 +- src/gateway/server.sessions-send.test.ts | 2 +- 7 files changed, 123 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46037f224..0ea5b5cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ - Control UI: logs tab opens at the newest entries (bottom). - Control UI: add Docs link, remove chat composer divider, and add New session button. - Control UI: link sessions list to chat view. (#471) — thanks @HazAT +- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c - Control UI: show/patch per-session reasoning level and render extracted reasoning in chat. - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow). diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index e5ce8360c..3f9a9a5c3 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -84,7 +84,7 @@ function ensureListener() { ? (evt.data.endedAt as number) : Date.now(); entry.endedAt = endedAt; - + if (!beginSubagentAnnounce(evt.runId)) { if (entry.cleanup === "delete") { subagentRuns.delete(evt.runId); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 5dbf5893d..061912094 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -29,12 +29,24 @@ import { resolvePingPongTurns, } from "./sessions-send-helpers.js"; -const SessionsSendToolSchema = Type.Object({ - sessionKey: Type.Optional(Type.String()), - label: Type.Optional(Type.String()), - message: Type.String(), - timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), -}); +const SessionsSendToolSchema = Type.Union([ + Type.Object( + { + sessionKey: Type.String(), + message: Type.String(), + timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, + ), + Type.Object( + { + label: Type.String(), + message: Type.String(), + timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, + ), +]); export function createSessionsSendTool(opts?: { agentSessionKey?: string; @@ -49,36 +61,8 @@ export function createSessionsSendTool(opts?: { parameters: SessionsSendToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - let sessionKey = readStringParam(params, "sessionKey"); - const labelParam = readStringParam(params, "label"); const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); - - // Lookup by label if sessionKey not provided - if (!sessionKey && labelParam) { - const listResult = (await callGateway({ - method: "sessions.list", - params: { activeMinutes: 1440 }, // Last 24h - timeoutMs: 10_000, - })) as { sessions?: Array<{ key: string; label?: string }> }; - const match = listResult.sessions?.find( - (s) => s.label === labelParam, - ); - if (!match) { - return jsonResult({ - status: "error", - error: `No session found with label: ${labelParam}`, - }); - } - sessionKey = match.key; - } - - if (!sessionKey) { - return jsonResult({ - status: "error", - error: "Either sessionKey or label is required", - }); - } const { mainKey, alias } = resolveMainSessionAlias(cfg); const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; @@ -90,42 +74,111 @@ export function createSessionsSendTool(opts?: { mainKey, }) : undefined; - const resolvedKey = resolveInternalSessionKey({ - key: sessionKey, - alias, - mainKey, - }); const restrictToSpawned = opts?.sandboxed === true && visibility === "spawned" && requesterInternalKey && !isSubagentSessionKey(requesterInternalKey); - if (restrictToSpawned) { - try { - const list = (await callGateway({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - limit: 500, - spawnedBy: requesterInternalKey, - }, - })) as { sessions?: Array> }; - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const ok = sessions.some((entry) => entry?.key === resolvedKey); - if (!ok) { + + const sessionKeyParam = readStringParam(params, "sessionKey"); + const labelParam = readStringParam(params, "label"); + if (sessionKeyParam && labelParam) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: "Provide either sessionKey or label (not both).", + }); + } + + const listSessions = async (listParams: Record) => { + const result = (await callGateway({ + method: "sessions.list", + params: listParams, + timeoutMs: 10_000, + })) as { sessions?: Array> }; + return Array.isArray(result?.sessions) ? result.sessions : []; + }; + + const activeMinutes = 24 * 60; + const visibleSessions = restrictToSpawned + ? await listSessions({ + activeMinutes, + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: requesterInternalKey, + }) + : undefined; + + let sessionKey = sessionKeyParam; + if (!sessionKey && labelParam) { + const sessions = + visibleSessions ?? + (await listSessions({ + activeMinutes, + includeGlobal: false, + includeUnknown: false, + limit: 500, + })); + const matches = sessions.filter((entry) => { + const label = + typeof entry?.label === "string" ? entry.label : undefined; + return label === labelParam; + }); + if (matches.length === 0) { + if (restrictToSpawned) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${sessionKey}`, - sessionKey: resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }), + error: `Session not visible from this sandboxed agent session: label=${labelParam}`, }); } - } catch { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: `No session found with label: ${labelParam}`, + }); + } + if (matches.length > 1) { + const keys = matches + .map((entry) => (typeof entry?.key === "string" ? entry.key : "")) + .filter(Boolean) + .join(", "); + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`, + }); + } + const key = matches[0]?.key; + if (typeof key !== "string" || !key.trim()) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: `Invalid session entry for label: ${labelParam}`, + }); + } + sessionKey = key; + } + + if (!sessionKey) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: "Either sessionKey or label is required", + }); + } + + const resolvedKey = resolveInternalSessionKey({ + key: sessionKey, + alias, + mainKey, + }); + + if (restrictToSpawned) { + const sessions = visibleSessions ?? []; + const ok = sessions.some((entry) => entry?.key === resolvedKey); + if (!ok) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index dd1c8a2cc..48bfc7cbc 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -271,7 +271,7 @@ export async function runReplyAgent(params: { if (steered && !shouldFollowup) { if (sessionEntry && sessionStore && sessionKey) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -285,7 +285,7 @@ export async function runReplyAgent(params: { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); if (sessionEntry && sessionStore && sessionKey) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -674,7 +674,7 @@ export async function runReplyAgent(params: { ) { sessionEntry.groupActivationNeedsSystemIntro = false; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index adf52410e..ce5248966 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -880,7 +880,7 @@ export async function handleDirectiveOnly(params: { } } sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -1099,7 +1099,7 @@ export async function persistInlineDirectives(params: { } if (updated) { sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fa759be0a..37b290309 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -95,7 +95,7 @@ export async function createModelSelectionState(params: { delete sessionEntry.providerOverride; delete sessionEntry.modelOverride; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -129,7 +129,7 @@ export async function createModelSelectionState(params: { if (!profile || profile.provider !== provider) { delete sessionEntry.authProfileOverride; sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + sessionStore[sessionKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 3972ef6ac..4e78115ed 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -173,7 +173,7 @@ describe("sessions_send label lookup", () => { }; expect(details.status).toBe("ok"); expect(details.reply).toBe("labeled response"); - expect(details.sessionKey).toBe("test-labeled-session"); + expect(details.sessionKey).toBe("agent:main:test-labeled-session"); } finally { if (prevPort === undefined) { delete process.env.CLAWDBOT_GATEWAY_PORT; From f7e8cd8ac83d92d9ea871910da7d5f7281b8c1f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:22:45 +0100 Subject: [PATCH 4/5] chore: regen protocol models (#570) (thanks @azade-c) --- .../Sources/ClawdbotProtocol/GatewayModels.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 713239414..9528dbf72 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -426,6 +426,8 @@ public struct AgentParams: Codable, Sendable { public let lane: String? public let extrasystemprompt: String? public let idempotencykey: String + public let label: String? + public let spawnedby: String? public init( message: String, @@ -438,7 +440,9 @@ public struct AgentParams: Codable, Sendable { timeout: Int?, lane: String?, extrasystemprompt: String?, - idempotencykey: String + idempotencykey: String, + label: String?, + spawnedby: String? ) { self.message = message self.to = to @@ -451,6 +455,8 @@ public struct AgentParams: Codable, Sendable { self.lane = lane self.extrasystemprompt = extrasystemprompt self.idempotencykey = idempotencykey + self.label = label + self.spawnedby = spawnedby } private enum CodingKeys: String, CodingKey { case message @@ -464,6 +470,8 @@ public struct AgentParams: Codable, Sendable { case lane case extrasystemprompt = "extraSystemPrompt" case idempotencykey = "idempotencyKey" + case label + case spawnedby = "spawnedBy" } } @@ -693,6 +701,7 @@ public struct SessionsListParams: Codable, Sendable { public struct SessionsPatchParams: Codable, Sendable { public let key: String + public let label: AnyCodable? public let thinkinglevel: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? @@ -705,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable { public init( key: String, + label: AnyCodable?, thinkinglevel: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, @@ -716,6 +726,7 @@ public struct SessionsPatchParams: Codable, Sendable { groupactivation: AnyCodable? ) { self.key = key + self.label = label self.thinkinglevel = thinkinglevel self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel @@ -728,6 +739,7 @@ public struct SessionsPatchParams: Codable, Sendable { } private enum CodingKeys: String, CodingKey { case key + case label case thinkinglevel = "thinkingLevel" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" From c4c0f1349ad8216e946bfccc8508e3517163325d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:40:36 +0100 Subject: [PATCH 5/5] fix: keep build green after main rebase (#570) (thanks @azade-c) --- src/agents/auth-profiles.ts | 4 +- src/auto-reply/reply/directive-handling.ts | 52 ++++-- src/cli/gateway-cli.ts | 5 +- src/cli/models-cli.ts | 8 +- src/cli/profile.test.ts | 7 +- src/commands/configure.ts | 1 + src/commands/models/auth-order.ts | 43 +++-- src/gateway/server.sessions-send.test.ts | 208 +++++++++++---------- 8 files changed, 189 insertions(+), 139 deletions(-) diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index c2ac71824..037ca0d29 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -718,9 +718,7 @@ export async function setAuthProfileOrder(params: { const providerKey = normalizeProviderId(params.provider); const sanitized = params.order && Array.isArray(params.order) - ? params.order - .map((entry) => String(entry).trim()) - .filter(Boolean) + ? params.order.map((entry) => String(entry).trim()).filter(Boolean) : []; const deduped: string[] = []; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index ce5248966..b7e4eaaf8 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -88,7 +88,9 @@ const resolveAuthLabel = async ( mode: ModelAuthDetailMode = "compact", ): Promise<{ label: string; source: string }> => { const formatPath = (value: string) => shortenHomePath(value); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const order = resolveAuthProfileOrder({ cfg, store, provider }); const providerKey = normalizeProviderId(provider); const lastGood = (() => { @@ -121,7 +123,8 @@ const resolveAuthLabel = async ( const configProfile = cfg.auth?.profiles?.[profileId]; const missing = !profile || - (configProfile?.provider && configProfile.provider !== profile.provider) || + (configProfile?.provider && + configProfile.provider !== profile.provider) || (configProfile?.mode && configProfile.mode !== profile.type && !(configProfile.mode === "oauth" && profile.type === "token")); @@ -170,7 +173,11 @@ const resolveAuthLabel = async ( if (lastGood && profileId === lastGood) flags.push("lastGood"); if (isProfileInCooldown(store, profileId)) { const until = store.usageStats?.[profileId]?.cooldownUntil; - if (typeof until === "number" && Number.isFinite(until) && until > now) { + if ( + typeof until === "number" && + Number.isFinite(until) && + until > now + ) { flags.push(`cooldown ${formatUntil(until)}`); } else { flags.push("cooldown"); @@ -197,7 +204,11 @@ const resolveAuthLabel = async ( Number.isFinite(profile.expires) && profile.expires > 0 ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); } const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`; @@ -218,7 +229,11 @@ const resolveAuthLabel = async ( Number.isFinite(profile.expires) && profile.expires > 0 ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); } const suffixLabel = suffix ? ` ${suffix}` : ""; const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; @@ -242,7 +257,8 @@ const resolveAuthLabel = async ( if (customKey) { return { label: maskApiKey(customKey), - source: mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", + source: + mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", }; } return { label: "missing", source: "missing" }; @@ -803,16 +819,16 @@ export async function handleDirectiveOnly(params: { } modelSelection = resolved.selection; if (modelSelection) { - if (directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: modelSelection.provider, - cfg: params.cfg, - agentDir, - }); - if (profileResolved.error) { - return { text: profileResolved.error }; - } + if (directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: directives.rawModelProfile, + provider: modelSelection.provider, + cfg: params.cfg, + agentDir, + }); + if (profileResolved.error) { + return { text: profileResolved.error }; + } profileOverride = profileResolved.profileId; } const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; @@ -994,6 +1010,10 @@ export async function persistInlineDirectives(params: { agentCfg, } = params; let { provider, model } = params; + const activeAgentId = sessionKey + ? resolveAgentIdFromSessionKey(sessionKey) + : resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, activeAgentId); if (sessionEntry && sessionStore && sessionKey) { let updated = false; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 11ecd42b9..f478b3293 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -180,7 +180,10 @@ async function ensureDevWorkspace(dir: string) { path.join(resolvedDir, "TOOLS.md"), DEV_TOOLS_TEMPLATE, ); - await writeFileIfMissing(path.join(resolvedDir, "USER.md"), DEV_USER_TEMPLATE); + await writeFileIfMissing( + path.join(resolvedDir, "USER.md"), + DEV_USER_TEMPLATE, + ); await writeFileIfMissing( path.join(resolvedDir, "HEARTBEAT.md"), DEV_HEARTBEAT_TEMPLATE, diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index dd2ca826b..ce897d66d 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -392,7 +392,9 @@ export function registerModelsCli(program: Command) { order .command("set") - .description("Set per-agent auth order override (locks rotation to this list)") + .description( + "Set per-agent auth order override (locks rotation to this list)", + ) .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .argument("", "Auth profile ids (e.g. anthropic:claude-cli)") @@ -414,7 +416,9 @@ export function registerModelsCli(program: Command) { order .command("clear") - .description("Clear per-agent auth order override (fall back to config/round-robin)") + .description( + "Clear per-agent auth order override (fall back to config/round-robin)", + ) .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .action(async (opts) => { diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index daf51071d..e67657713 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -23,12 +23,7 @@ describe("parseCliProfileArgs", () => { }); it("still accepts global --dev before subcommand", () => { - const res = parseCliProfileArgs([ - "node", - "clawdbot", - "--dev", - "gateway", - ]); + const res = parseCliProfileArgs(["node", "clawdbot", "--dev", "gateway"]); if (!res.ok) throw new Error(res.error); expect(res.profile).toBe("dev"); expect(res.argv).toEqual(["node", "clawdbot", "gateway"]); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index f915f439e..d75a1527e 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -80,6 +80,7 @@ import { DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, + openUrl, printWizardHeader, probeGatewayReachable, randomToken, diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index e0429a372..4af49e63c 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -1,16 +1,22 @@ -import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { + resolveAgentDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; +import { + type AuthProfileStore, ensureAuthProfileStore, setAuthProfileOrder, - type AuthProfileStore, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { loadConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; -function resolveTargetAgent(cfg: ReturnType, raw?: string): { +function resolveTargetAgent( + cfg: ReturnType, + raw?: string, +): { agentId: string; agentDir: string; } { @@ -37,7 +43,9 @@ export async function modelsAuthOrderGetCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const order = describeOrder(store, provider); if (opts.json) { @@ -59,9 +67,13 @@ export async function modelsAuthOrderGetCommand( runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); - runtime.log(`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`); runtime.log( - order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)", + `Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`, + ); + runtime.log( + order.length > 0 + ? `Order override: ${order.join(", ")}` + : "Order override: (none)", ); } @@ -75,8 +87,13 @@ export async function modelsAuthOrderClearCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const updated = await setAuthProfileOrder({ agentDir, provider, order: null }); - if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + const updated = await setAuthProfileOrder({ + agentDir, + provider, + order: null, + }); + if (!updated) + throw new Error("Failed to update auth-profiles.json (lock busy?)."); runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); @@ -94,7 +111,9 @@ export async function modelsAuthOrderSetCommand( const cfg = loadConfig(); const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); - const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const providerKey = normalizeProviderId(provider); const requested = (opts.order ?? []) .map((entry) => String(entry).trim()) @@ -120,10 +139,10 @@ export async function modelsAuthOrderSetCommand( provider, order: requested, }); - if (!updated) throw new Error("Failed to update auth-profiles.json (lock busy?)."); + if (!updated) + throw new Error("Failed to update auth-profiles.json (lock busy?)."); runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); runtime.log(`Order override: ${describeOrder(updated, provider).join(", ")}`); } - diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 4e78115ed..acfbbf38a 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -103,88 +103,92 @@ describe("sessions_send gateway loopback", () => { }); describe("sessions_send label lookup", () => { - it("finds session by label and sends message", async () => { - const port = await getFreePort(); - const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; - process.env.CLAWDBOT_GATEWAY_PORT = String(port); + it( + "finds session by label and sends message", + { timeout: 15_000 }, + async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); - const server = await startGatewayServer(port); - const spy = vi.mocked(agentCommand); - spy.mockImplementation(async (opts) => { - const params = opts as { - sessionId?: string; - runId?: string; - extraSystemPrompt?: string; - }; - const sessionId = params.sessionId ?? "test-labeled"; - const runId = params.runId ?? sessionId; - const sessionFile = resolveSessionTranscriptPath(sessionId); - await fs.mkdir(path.dirname(sessionFile), { recursive: true }); + const server = await startGatewayServer(port); + const spy = vi.mocked(agentCommand); + spy.mockImplementation(async (opts) => { + const params = opts as { + sessionId?: string; + runId?: string; + extraSystemPrompt?: string; + }; + const sessionId = params.sessionId ?? "test-labeled"; + const runId = params.runId ?? sessionId; + const sessionFile = resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(sessionFile), { recursive: true }); - const startedAt = Date.now(); - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { phase: "start", startedAt }, + const startedAt = Date.now(); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt }, + }); + + const text = "labeled response"; + const message = { + role: "assistant", + content: [{ type: "text", text }], + }; + await fs.appendFile( + sessionFile, + `${JSON.stringify({ message })}\n`, + "utf8", + ); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt, endedAt: Date.now() }, + }); }); - const text = "labeled response"; - const message = { - role: "assistant", - content: [{ type: "text", text }], - }; - await fs.appendFile( - sessionFile, - `${JSON.stringify({ message })}\n`, - "utf8", - ); + try { + // First, create a session with a label via sessions.patch + const { callGateway } = await import("./call.js"); + await callGateway({ + method: "sessions.patch", + params: { key: "test-labeled-session", label: "my-test-worker" }, + timeoutMs: 5000, + }); - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { phase: "end", startedAt, endedAt: Date.now() }, - }); - }); + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); - try { - // First, create a session with a label via sessions.patch - const { callGateway } = await import("./call.js"); - await callGateway({ - method: "sessions.patch", - params: { key: "test-labeled-session", label: "my-test-worker" }, - timeoutMs: 5000, - }); - - const tool = createClawdbotTools().find( - (candidate) => candidate.name === "sessions_send", - ); - if (!tool) throw new Error("missing sessions_send tool"); - - // Send using label instead of sessionKey - const result = await tool.execute("call-by-label", { - label: "my-test-worker", - message: "hello labeled session", - timeoutSeconds: 5, - }); - const details = result.details as { - status?: string; - reply?: string; - sessionKey?: string; - }; - expect(details.status).toBe("ok"); - expect(details.reply).toBe("labeled response"); - expect(details.sessionKey).toBe("agent:main:test-labeled-session"); - } finally { - if (prevPort === undefined) { - delete process.env.CLAWDBOT_GATEWAY_PORT; - } else { - process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + // Send using label instead of sessionKey + const result = await tool.execute("call-by-label", { + label: "my-test-worker", + message: "hello labeled session", + timeoutSeconds: 5, + }); + const details = result.details as { + status?: string; + reply?: string; + sessionKey?: string; + }; + expect(details.status).toBe("ok"); + expect(details.reply).toBe("labeled response"); + expect(details.sessionKey).toBe("agent:main:test-labeled-session"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); } - await server.close(); - } - }); + }, + ); - it("returns error when label not found", async () => { + it("returns error when label not found", { timeout: 15_000 }, async () => { const port = await getFreePort(); const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; process.env.CLAWDBOT_GATEWAY_PORT = String(port); @@ -215,33 +219,39 @@ describe("sessions_send label lookup", () => { } }); - it("returns error when neither sessionKey nor label provided", async () => { - const port = await getFreePort(); - const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; - process.env.CLAWDBOT_GATEWAY_PORT = String(port); + it( + "returns error when neither sessionKey nor label provided", + { timeout: 15_000 }, + async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); - const server = await startGatewayServer(port); + const server = await startGatewayServer(port); - try { - const tool = createClawdbotTools().find( - (candidate) => candidate.name === "sessions_send", - ); - if (!tool) throw new Error("missing sessions_send tool"); + try { + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); - const result = await tool.execute("call-no-key", { - message: "hello", - timeoutSeconds: 5, - }); - const details = result.details as { status?: string; error?: string }; - expect(details.status).toBe("error"); - expect(details.error).toContain("Either sessionKey or label is required"); - } finally { - if (prevPort === undefined) { - delete process.env.CLAWDBOT_GATEWAY_PORT; - } else { - process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + const result = await tool.execute("call-no-key", { + message: "hello", + timeoutSeconds: 5, + }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toContain( + "Either sessionKey or label is required", + ); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); } - await server.close(); - } - }); + }, + ); });