Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available.
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
|
||||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
- `/cost on|off` in chats: toggles per‑response 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).
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Everything the model receives counts toward the context limit:
|
|||||||
|
|
||||||
Use these in chat:
|
Use these in chat:
|
||||||
|
|
||||||
- `/status` → **compact one‑liner** with the session model, context usage,
|
- `/status` → **emoji‑rich 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`).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user