From 3133c7c84e8a23a0fff66de0b6de5ebd59149917 Mon Sep 17 00:00:00 2001 From: Azade Date: Thu, 8 Jan 2026 23:17:08 +0000 Subject: [PATCH] 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)}