Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-08 22:19:52 -05:00
committed by GitHub
13 changed files with 327 additions and 61 deletions

View File

@@ -33,6 +33,7 @@
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Control UI: logs tab opens at the newest entries (bottom). - 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: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
@@ -67,6 +68,7 @@
- Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages. - Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
## 2026.1.8 ## 2026.1.8

View File

@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows. - No estimated costs; only the provider-reported windows.
## Where it shows up ## Where it shows up
- `/status` in chats: compact oneliner with session tokens + estimated cost (API key only) and provider quota windows when available. - `/status` in chats: emojirich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
- `/cost on|off` in chats: toggles perresponse usage lines (OAuth shows tokens only). - `/cost on|off` in chats: toggles perresponse usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).

View File

@@ -38,7 +38,7 @@ Everything the model receives counts toward the context limit:
Use these in chat: Use these in chat:
- `/status`**compact oneliner** with the session model, context usage, - `/status`**emojirich status card** with the session model, context usage,
last response input/output tokens, and **estimated cost** (API key only). last response input/output tokens, and **estimated cost** (API key only).
- `/cost on|off` → appends a **per-response usage line** to every reply. - `/cost on|off` → appends a **per-response usage line** to every reply.
- Persists per session (stored as `responseUsage`). - Persists per session (stored as `responseUsage`).

View File

@@ -933,6 +933,36 @@ describe("directive behavior", () => {
}); });
}); });
it("falls back to configured models when catalog is unavailable", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model catalog unavailable");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("does not repeat missing auth labels on /model list", async () => { it("does not repeat missing auth labels on /model list", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -213,7 +213,7 @@ describe("trigger handling", () => {
makeCfg(home), makeCfg(home),
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("status"); expect(text).toContain("ClawdBot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -165,6 +165,7 @@ export async function buildStatusReply(params: {
defaultGroupActivation()) defaultGroupActivation())
: undefined; : undefined;
const statusText = buildStatusMessage({ const statusText = buildStatusMessage({
config: cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
model: { model: {
@@ -566,6 +567,7 @@ export async function handleCommands(params: {
const reply = await buildStatusReply({ const reply = await buildStatusReply({
cfg, cfg,
command, command,
provider: command.provider,
sessionEntry, sessionEntry,
sessionKey, sessionKey,
sessionScope, sessionScope,

View File

@@ -379,12 +379,92 @@ export async function handleDirectiveOnly(params: {
modelDirective === "status" || modelDirective === "list"; modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) { if (!directives.rawModelDirective || isModelListAlias) {
if (allowedModelCatalog.length === 0) { if (allowedModelCatalog.length === 0) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const fallbackKeys = new Set<string>();
const fallbackCatalog: Array<{
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (fallbackKeys.has(key)) continue;
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolved.ref.provider,
id: resolved.ref.model,
});
}
if (fallbackCatalog.length === 0 && resolvedDefault.model) {
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
});
}
if (fallbackCatalog.length === 0) {
return { text: "No models available." }; return { text: "No models available." };
} }
const agentDir = resolveClawdbotAgentDir(); const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`; const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value); const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>(); const authByProvider = new Map<string, string>();
for (const entry of fallbackCatalog) {
if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
params.cfg,
modelsPath,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`⚠️ Model catalog unavailable; showing configured models only.`,
];
const byProvider = new Map<string, typeof fallbackCatalog>();
for (const entry of fallbackCatalog) {
const models = byProvider.get(entry.provider);
if (models) {
models.push(entry);
continue;
}
byProvider.set(entry.provider, [entry]);
}
for (const provider of byProvider.keys()) {
const models = byProvider.get(provider);
if (!models) continue;
const authLabel = authByProvider.get(provider) ?? "missing";
lines.push("");
lines.push(`[${provider}] auth: ${authLabel}`);
for (const entry of models) {
const label = `${entry.provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
lines.push(`${label}${aliasSuffix}`);
}
}
return { text: lines.join("\n") };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) { for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue; if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel( const auth = await resolveAuthLabel(

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js"; import { buildCommandsMessage, buildStatusMessage } from "./status.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
@@ -45,6 +46,27 @@ afterEach(() => {
describe("buildStatusMessage", () => { describe("buildStatusMessage", () => {
it("summarizes agent readiness and context usage", () => { it("summarizes agent readiness and context usage", () => {
const text = buildStatusMessage({ const text = buildStatusMessage({
config: {
models: {
providers: {
anthropic: {
apiKey: "test-key",
models: [
{
id: "pi:opus",
cost: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
},
},
} as ClawdbotConfig,
agent: { agent: {
model: "anthropic/pi:opus", model: "anthropic/pi:opus",
contextTokens: 32_000, contextTokens: 32_000,
@@ -52,6 +74,8 @@ describe("buildStatusMessage", () => {
sessionEntry: { sessionEntry: {
sessionId: "abc", sessionId: "abc",
updatedAt: 0, updatedAt: 0,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 16_000, totalTokens: 16_000,
contextTokens: 32_000, contextTokens: 32_000,
thinkingLevel: "low", thinkingLevel: "low",
@@ -64,17 +88,22 @@ describe("buildStatusMessage", () => {
resolvedVerbose: "off", resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
modelAuth: "api-key", modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later
}); });
expect(text).toContain("status agent:main:main"); expect(text).toContain("🦞 ClawdBot");
expect(text).toContain("model anthropic/pi:opus (api-key)"); expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key");
expect(text).toContain("Context 16k/32k (50%)"); expect(text).toContain("🧮 Tokens: 1.2k in / 800 out");
expect(text).toContain("compactions 2"); expect(text).toContain("💵 Cost: $0.0020");
expect(text).toContain("think medium"); expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("verbose off"); expect(text).toContain("🧹 Compactions: 2");
expect(text).toContain("reasoning off"); expect(text).toContain("Session: agent:main:main");
expect(text).toContain("elevated on"); expect(text).toContain("updated 10m ago");
expect(text).toContain("queue collect"); expect(text).toContain("Runtime: direct");
expect(text).toContain("Think: medium");
expect(text).toContain("Verbose: off");
expect(text).toContain("Elevated: on");
expect(text).toContain("Queue: collect");
}); });
it("shows verbose/elevated labels only when enabled", () => { it("shows verbose/elevated labels only when enabled", () => {
@@ -89,8 +118,8 @@ describe("buildStatusMessage", () => {
queue: { mode: "collect", depth: 0 }, queue: { mode: "collect", depth: 0 },
}); });
expect(text).toContain("verbose on"); expect(text).toContain("Verbose: on");
expect(text).toContain("elevated on"); expect(text).toContain("Elevated: on");
}); });
it("prefers model overrides over last-run model", () => { it("prefers model overrides over last-run model", () => {
@@ -114,7 +143,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("model openai/gpt-4.1-mini"); expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
}); });
it("keeps provider prefix from configured model", () => { it("keeps provider prefix from configured model", () => {
@@ -127,7 +156,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("model google-antigravity/claude-sonnet-4-5"); expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
}); });
it("handles missing agent config gracefully", () => { it("handles missing agent config gracefully", () => {
@@ -138,9 +167,9 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("model"); expect(text).toContain("🧠 Model:");
expect(text).toContain("Context"); expect(text).toContain("Context:");
expect(text).toContain("queue collect"); expect(text).toContain("Queue: collect");
}); });
it("includes group activation for group sessions", () => { it("includes group activation for group sessions", () => {
@@ -158,7 +187,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("activation always"); expect(text).toContain("Activation: always");
}); });
it("shows queue details when overridden", () => { it("shows queue details when overridden", () => {
@@ -179,7 +208,7 @@ describe("buildStatusMessage", () => {
}); });
expect(text).toContain( expect(text).toContain(
"queue collect (depth 3 · debounce 2s · cap 5 · drop old)", "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)",
); );
}); });
@@ -194,7 +223,43 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("📊 Usage: Claude 80% left (5h)"); const lines = text.split("\n");
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
expect(contextIndex).toBeGreaterThan(-1);
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
});
it("hides cost when not using an API key", () => {
const text = buildStatusMessage({
config: {
models: {
providers: {
anthropic: {
models: [
{
id: "claude-opus-4-5",
cost: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
},
},
} as ClawdbotConfig,
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "c1", updatedAt: 0, inputTokens: 10 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "oauth",
});
expect(text).not.toContain("💵 Cost:");
}); });
it("prefers cached prompt tokens from the session log", async () => { it("prefers cached prompt tokens from the session log", async () => {
@@ -257,7 +322,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key", modelAuth: "api-key",
}); });
expect(text).toContain("Context 1.0k/32k"); expect(text).toContain("Context: 1.0k/32k");
} finally { } finally {
restoreHomeEnv(previousHome); restoreHomeEnv(previousHome);
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });

View File

@@ -15,6 +15,7 @@ import {
} from "../agents/usage.js"; } from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
resolveMainSessionKey,
resolveSessionFilePath, resolveSessionFilePath,
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
@@ -63,6 +64,7 @@ type StatusArgs = {
usageLine?: string; usageLine?: string;
queue?: QueueStatus; queue?: QueueStatus;
includeTranscriptUsage?: boolean; includeTranscriptUsage?: boolean;
now?: number;
}; };
const formatTokens = ( const formatTokens = (
@@ -85,6 +87,17 @@ export const formatContextUsageShort = (
contextTokens: number | null | undefined, contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`; ) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const formatQueueDetails = (queue?: QueueStatus) => { const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) return ""; if (!queue) return "";
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null; const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
@@ -169,10 +182,11 @@ const formatUsagePair = (input?: number | null, output?: number | null) => {
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel = const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?"; typeof output === "number" ? formatTokenCount(output) : "?";
return `usage ${inputLabel} in / ${outputLabel} out`; return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`;
}; };
export function buildStatusMessage(args: StatusArgs): string { export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry; const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} }, cfg: { agent: args.agent ?? {} },
@@ -222,6 +236,33 @@ export function buildStatusMessage(args: StatusArgs): string {
args.agent?.elevatedDefault ?? args.agent?.elevatedDefault ??
"on"; "on";
const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return { label: "direct" };
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
});
const sessionKey = args.sessionKey?.trim();
const sandboxed = sessionKey
? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
return {
label: `${runtime}/${sandboxMode}`,
};
})();
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
typeof updatedAt === "number"
? `updated ${formatAge(now - updatedAt)}`
: "no activity",
]
.filter(Boolean)
.join(" • ");
const isGroupSession = const isGroupSession =
entry?.chatType === "group" || entry?.chatType === "group" ||
entry?.chatType === "room" || entry?.chatType === "room" ||
@@ -232,8 +273,30 @@ export function buildStatusMessage(args: StatusArgs): string {
? (args.groupActivation ?? entry?.groupActivation ?? "mention") ? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined; : undefined;
const authMode = const contextLine = [
args.modelAuth ?? resolveModelAuthMode(provider, args.config); `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
`🧹 Compactions: ${entry?.compactionCount ?? 0}`,
]
.filter(Boolean)
.join(" · ");
const queueMode = args.queue?.mode ?? "unknown";
const queueDetails = formatQueueDetails(args.queue);
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
`Verbose: ${verboseLevel}`,
reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
`Elevated: ${elevatedLevel}`,
];
const optionsLine = optionParts.filter(Boolean).join(" · ");
const activationParts = [
groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
`🪢 Queue: ${queueMode}${queueDetails}`,
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const authMode = resolveModelAuthMode(provider, args.config);
const showCost = authMode === "api-key"; const showCost = authMode === "api-key";
const costConfig = showCost const costConfig = showCost
? resolveModelCostConfig({ ? resolveModelCostConfig({
@@ -256,36 +319,30 @@ export function buildStatusMessage(args: StatusArgs): string {
: undefined; : undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const parts: Array<string | null> = [];
parts.push(`status ${args.sessionKey ?? "unknown"}`);
const modelLabel = model ? `${provider}/${model}` : "unknown"; const modelLabel = model ? `${provider}/${model}` : "unknown";
const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : ""; const authLabelValue =
parts.push(`model ${modelLabel}${authLabel}`); args.modelAuth ??
(authMode && authMode !== "unknown" ? authMode : undefined);
const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : "";
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const commit = resolveCommitHash();
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
const usagePair = formatUsagePair(inputTokens, outputTokens); const usagePair = formatUsagePair(inputTokens, outputTokens);
if (usagePair) parts.push(usagePair); const costLine = costLabel ? `💵 Cost: ${costLabel}` : null;
if (costLabel) parts.push(`cost ${costLabel}`);
const contextSummary = formatContextUsageShort( return [
totalTokens && totalTokens > 0 ? totalTokens : null, versionLine,
contextTokens ?? null, modelLine,
); usagePair,
parts.push(contextSummary); costLine,
parts.push(`compactions ${entry?.compactionCount ?? 0}`); `📚 ${contextLine}`,
parts.push(`think ${thinkLevel}`); args.usageLine,
parts.push(`verbose ${verboseLevel}`); `🧵 ${sessionLine}`,
parts.push(`reasoning ${reasoningLevel}`); `⚙️ ${optionsLine}`,
parts.push(`elevated ${elevatedLevel}`); activationLine,
if (groupActivationValue) parts.push(`activation ${groupActivationValue}`); ]
.filter(Boolean)
const queueMode = args.queue?.mode ?? "unknown"; .join("\n");
const queueDetails = formatQueueDetails(args.queue);
parts.push(`queue ${queueMode}${queueDetails}`);
if (args.usageLine) parts.push(args.usageLine);
return parts.filter(Boolean).join(" · ");
} }
export function buildHelpMessage(): string { export function buildHelpMessage(): string {

View File

@@ -454,6 +454,15 @@
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
} }
.session-link {
text-decoration: none;
color: var(--accent);
}
.session-link:hover {
text-decoration: underline;
}
.log-stream { .log-stream {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;

View File

@@ -358,6 +358,7 @@ export function renderApp(state: AppViewState) {
limit: state.sessionsFilterLimit, limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal, includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown, includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => { onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit; state.sessionsFilterLimit = next.limit;

View File

@@ -824,27 +824,37 @@ export class ClawdbotApp extends LitElement {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const tokenRaw = params.get("token"); const tokenRaw = params.get("token");
const passwordRaw = params.get("password"); const passwordRaw = params.get("password");
let changed = false; const sessionRaw = params.get("session");
let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
const token = tokenRaw.trim(); const token = tokenRaw.trim();
if (token && !this.settings.token) { if (token && !this.settings.token) {
this.applySettings({ ...this.settings, token }); this.applySettings({ ...this.settings, token });
changed = true;
} }
params.delete("token"); params.delete("token");
shouldCleanUrl = true;
} }
if (passwordRaw != null) { if (passwordRaw != null) {
const password = passwordRaw.trim(); const password = passwordRaw.trim();
if (password) { if (password) {
this.password = password; this.password = password;
changed = true;
} }
params.delete("password"); params.delete("password");
shouldCleanUrl = true;
} }
if (!changed && tokenRaw == null && passwordRaw == null) return; if (sessionRaw != null) {
const session = sessionRaw.trim();
if (session) {
this.sessionKey = session;
}
params.delete("session");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.search = params.toString(); url.search = params.toString();
window.history.replaceState({}, "", url.toString()); window.history.replaceState({}, "", url.toString());

View File

@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter"; import { formatSessionTokens } from "../presenter";
import { pathForTab } from "../navigation";
import type { GatewaySessionRow, SessionsListResult } from "../types"; import type { GatewaySessionRow, SessionsListResult } from "../types";
export type SessionsProps = { export type SessionsProps = {
@@ -12,6 +13,7 @@ export type SessionsProps = {
limit: string; limit: string;
includeGlobal: boolean; includeGlobal: boolean;
includeUnknown: boolean; includeUnknown: boolean;
basePath: string;
onFiltersChange: (next: { onFiltersChange: (next: {
activeMinutes: string; activeMinutes: string;
limit: string; limit: string;
@@ -118,19 +120,27 @@ export function renderSessions(props: SessionsProps) {
</div> </div>
${rows.length === 0 ${rows.length === 0
? html`<div class="muted">No sessions found.</div>` ? html`<div class="muted">No sessions found.</div>`
: rows.map((row) => renderRow(row, props.onPatch))} : rows.map((row) => renderRow(row, props.basePath, props.onPatch))}
</div> </div>
</section> </section>
`; `;
} }
function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) { function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsProps["onPatch"]) {
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
const thinking = row.thinkingLevel ?? ""; const thinking = row.thinkingLevel ?? "";
const verbose = row.verboseLevel ?? ""; const verbose = row.verboseLevel ?? "";
const displayName = row.displayName ?? row.key;
const canLink = row.kind !== "global";
const chatUrl = canLink
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
: null;
return html` return html`
<div class="table-row"> <div class="table-row">
<div class="mono">${row.displayName ?? row.key}</div> <div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div>${row.kind}</div> <div>${row.kind}</div>
<div>${updated}</div> <div>${updated}</div>
<div>${formatSessionTokens(row)}</div> <div>${formatSessionTokens(row)}</div>