Compare commits

..
Author SHA1 Message Date
Peter Steinberger ee2cfa5ca1 fix: harden voice-call webhooks (#1213) (thanks @andrew-kurin) 2026-01-19 06:00:01 +00:00
Ghost 0e1ebc7da1 Voice-call: avoid streaming on notify callbacks 2026-01-19 05:48:06 +00:00
Ghost 0e3e6f6b70 Voice-call: fix Twilio status callbacks 2026-01-19 05:48:06 +00:00
Ghost 337dd41ce9 Voice-call: fix Twilio signature ordering 2026-01-19 05:48:06 +00:00
Ghost 88336ef4ea Voice-call: fix tailscale tunnel path 2026-01-19 05:48:06 +00:00
Peter Steinberger 5ee03c82b4 Merge pull request #1212 from longmaba/fix/ui-build-windows-spawn
fix(ui): enable shell mode for spawn on Windows
2026-01-19 05:43:15 +00:00
Peter Steinberger 111aeb2c4f fix: cover sync ui spawn on Windows (#1212) (thanks @longmaba) 2026-01-19 05:42:42 +00:00
Long 23c2c638b7 fix(ui): enable shell mode for spawn on Windows 2026-01-19 05:41:38 +00:00
Peter Steinberger 6b8299eb33 chore: update package resolutions 2026-01-19 05:40:04 +00:00
Peter Steinbergerandgumadeiras 9822a53649 fix: centralize cli command registry
Co-authored-by: gumadeiras <gumadeiras@users.noreply.github.com>
2026-01-19 05:36:09 +00:00
Peter Steinberger 81d392a2d7 refactor: extract TUI syntax theme and fix changelog 2026-01-19 05:32:53 +00:00
Peter Steinberger dbcec3ffaf docs: clarify session log agent id 2026-01-19 05:27:52 +00:00
Peter Steinberger 50c5231486 refactor: reuse prompt params in context report 2026-01-19 05:27:52 +00:00
Peter Steinberger 55d034358d refactor: unify system prompt runtime params 2026-01-19 05:27:52 +00:00
Peter Steinberger 374da34936 fix: canonicalize legacy session keys 2026-01-19 05:17:31 +00:00
Peter SteinbergerandVignesh Natarajan c578fca687 fix(tui): generic empty-state for searchable pickers (PR #1201, thanks @vignesh07)
Co-authored-by: Vignesh Natarajan <vigneshnatarajan92@gmail.com>
2026-01-19 05:16:06 +00:00
Vignesh Natarajan dd18765b50 feat(tui): add fuzzy search to session and agent pickers
Use SearchableSelectList for /sessions and /agents pickers,
matching the /models picker behavior.

- Session picker: search by session key, display name, or date
- Agent picker: search by agent ID or name

🤖 AI-assisted (Claude)
2026-01-19 05:16:06 +00:00
Peter Steinberger 3e06fe84dc feat: add TUI code block syntax highlighting (#1200) (thanks @vignesh07) 2026-01-19 05:07:23 +00:00
Peter Steinberger 640e19988f Merge pull request #1200 from vignesh07/feat/tui-syntax-highlighting
feat(tui): add syntax highlighting for code blocks
2026-01-19 05:05:51 +00:00
Vignesh Natarajan 145adf540f fix: make syntax highlighting tests environment-agnostic
Tests now verify structure and content preservation rather than
checking for specific ANSI escape codes, which may not be present
in CI environments without TTY/color support.
2026-01-18 16:40:06 -08:00
Vignesh Natarajan 0e3c9e4a0e feat(tui): add syntax highlighting for code blocks
Add syntax highlighting to markdown code blocks in the TUI using
cli-highlight with a VS Code Dark-inspired color theme.

Features:
- 191 languages supported via highlight.js
- Auto-detection fallback for unknown languages
- Graceful fallback to plain styling on errors
- VS Code Dark-inspired color palette

Colors:
- Purple: keywords (const, function, if, etc.)
- Teal: built-ins (console, Math, print, etc.)
- Orange: strings
- Green: numbers, comments
- Yellow: function names
- Blue: literals (true, false, null)
- Red: diff deletions
- Light blue: variables, parameters

🤖 AI-assisted (Claude) - fully tested locally
2026-01-18 16:24:14 -08:00
36 changed files with 828 additions and 283 deletions

No files matched your search

+2 -19
View File
@@ -66,7 +66,7 @@
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
@@ -116,8 +116,7 @@
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
@@ -133,19 +132,3 @@
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdbot bug.
+5 -4
View File
@@ -13,13 +13,14 @@ Docs: https://docs.clawd.bot
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
<<<<<<< Updated upstream
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
||||||| Stash base
=======
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
>>>>>>> Stashed changes
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
## 2026.1.18-5
+1 -1
View File
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
+1 -1
View File
@@ -24,7 +24,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
+28 -8
View File
@@ -64,17 +64,24 @@ export class TwilioProvider implements VoiceCallProvider {
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
private readonly twimlStorage = new Map<string, string>();
/** Track notify-mode calls to avoid streaming on follow-up callbacks. */
private readonly notifyCalls = new Set<string>();
/**
* Delete stored TwiML for a given `callId`.
*
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
* webhook request (notify mode). Subsequent webhooks should not reuse it.
* webhook request (notify mode). The notify guard is cleared separately.
*/
private deleteStoredTwiml(callId: string): void {
this.twimlStorage.delete(callId);
}
private clearNotifyState(callId: string): void {
this.twimlStorage.delete(callId);
this.notifyCalls.delete(callId);
}
/**
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
*
@@ -87,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
if (!callIdMatch) return;
this.deleteStoredTwiml(callIdMatch[1]);
this.clearNotifyState(callIdMatch[1]);
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
@@ -137,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
*/
private async apiRequest<T = unknown>(
endpoint: string,
params: Record<string, string>,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
): Promise<T> {
return await twilioApiRequest<T>({
@@ -257,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
case "no-answer":
case "failed":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
this.clearNotifyState(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
this.clearNotifyState(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
default:
@@ -286,6 +293,9 @@ export class TwilioProvider implements VoiceCallProvider {
if (!ctx) return TwilioProvider.EMPTY_TWIML;
const params = new URLSearchParams(ctx.rawBody);
const type =
typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
const isStatusCallback = type === "status";
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const callIdFromQuery =
@@ -297,13 +307,21 @@ export class TwilioProvider implements VoiceCallProvider {
// Handle initial TwiML request (when Twilio first initiates the call)
// Check if we have stored TwiML for this call (notify mode)
if (callIdFromQuery) {
if (callIdFromQuery && !isStatusCallback) {
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
if (storedTwiml) {
// Clean up after serving (one-time use)
this.deleteStoredTwiml(callIdFromQuery);
return storedTwiml;
}
if (this.notifyCalls.has(callIdFromQuery)) {
return TwilioProvider.EMPTY_TWIML;
}
}
// Status callbacks should not receive TwiML.
if (isStatusCallback) {
return TwilioProvider.EMPTY_TWIML;
}
// Handle subsequent webhook requests (status callbacks, etc.)
@@ -385,16 +403,18 @@ export class TwilioProvider implements VoiceCallProvider {
// We now serve it from the webhook endpoint instead of sending inline
if (input.inlineTwiml) {
this.twimlStorage.set(input.callId, input.inlineTwiml);
// Keep notify-mode calls out of streaming TwiML for any follow-up callbacks.
this.notifyCalls.add(input.callId);
}
// Build request params - always use URL-based TwiML.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
const params: Record<string, string> = {
const params: Record<string, string | string[]> = {
To: input.to,
From: input.from,
Url: url.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallbackEvent: "initiated ringing answered completed",
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
Timeout: "30",
};
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { twilioApiRequest } from "./api.js";
describe("twilioApiRequest", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("encodes array params as repeated form fields", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => "",
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
await twilioApiRequest({
baseUrl: "https://api.example.com",
accountSid: "AC123",
authToken: "token",
endpoint: "/Calls.json",
body: {
To: "+15555550123",
StatusCallbackEvent: ["initiated", "completed"],
},
});
const [, init] = fetchMock.mock.calls[0] ?? [];
const body = init?.body as URLSearchParams | undefined;
expect(body?.getAll("StatusCallbackEvent")).toEqual([
"initiated",
"completed",
]);
expect(body?.get("To")).toBe("+15555550123");
});
});
@@ -3,16 +3,34 @@ export async function twilioApiRequest<T = unknown>(params: {
accountSid: string;
authToken: string;
endpoint: string;
body: Record<string, string>;
body: URLSearchParams | Record<string, string | string[]>;
allowNotFound?: boolean;
}): Promise<T> {
const bodyParams =
params.body instanceof URLSearchParams
? params.body
: Object.entries(params.body).reduce<URLSearchParams>(
(acc, [key, value]) => {
if (Array.isArray(value)) {
for (const entry of value) {
acc.append(key, entry);
}
} else if (typeof value === "string") {
acc.append(key, value);
}
return acc;
},
new URLSearchParams(),
);
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params.body),
// URL-encoded body required by Twilio, with repeated keys for arrays.
body: bodyParams,
});
if (!response.ok) {
@@ -26,4 +44,3 @@ export async function twilioApiRequest<T = unknown>(params: {
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
}
+5 -3
View File
@@ -230,12 +230,14 @@ export async function startTailscaleTunnel(config: {
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
}
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
// --set-path already mounts the path; keep the target base URL to avoid /path/path.
const localUrl = `http://127.0.0.1:${config.port}`;
return new Promise((resolve, reject) => {
const proc = spawn(
"tailscale",
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
{ stdio: ["ignore", "pipe", "pipe"] },
);
@@ -247,7 +249,7 @@ export async function startTailscaleTunnel(config: {
proc.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
const publicUrl = `https://${dnsName}${config.path}`;
const publicUrl = `https://${dnsName}${path}`;
console.log(
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
);
@@ -256,7 +258,7 @@ export async function startTailscaleTunnel(config: {
publicUrl,
provider: `tailscale-${config.mode}`,
stop: async () => {
await stopTailscaleTunnel(config.mode, config.path);
await stopTailscaleTunnel(config.mode, path);
},
});
} else {
@@ -79,7 +79,7 @@ function twilioSignature(params: {
let dataToSign = params.url;
const sortedParams = Array.from(
new URLSearchParams(params.postBody).entries(),
).sort((a, b) => a[0].localeCompare(b[0]));
).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [key, value] of sortedParams) {
dataToSign += key + value;
@@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(true);
});
it("sorts params using ASCII order", () => {
const authToken = "test-auth-token";
const publicUrl = "https://example.com/voice/webhook";
const postBody = "Z=first&a=second";
const signature = twilioSignature({
authToken,
url: publicUrl,
postBody,
});
const result = verifyTwilioWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://local/voice/webhook",
method: "POST",
},
authToken,
{ publicUrl },
);
expect(result.ok).toBe(true);
});
});
@@ -23,9 +23,9 @@ export function validateTwilioSignature(
// Build the string to sign: URL + sorted params (key+value pairs)
let dataToSign = url;
// Sort params alphabetically and append key+value
// Sort params by ASCII order (Twilio spec) and append key+value.
const sortedParams = Array.from(params.entries()).sort((a, b) =>
a[0].localeCompare(b[0]),
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
);
for (const [key, value] of sortedParams) {
+2
View File
@@ -55,6 +55,7 @@ function run(cmd, args) {
cwd: uiDir,
stdio: "inherit",
env: process.env,
shell: process.platform === "win32",
});
child.on("exit", (code, signal) => {
if (signal) process.exit(1);
@@ -67,6 +68,7 @@ function runSync(cmd, args, envOverride) {
cwd: uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
shell: process.platform === "win32",
});
if (result.signal) process.exit(1);
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
+21 -11
View File
@@ -1,16 +1,20 @@
---
name: session-logs
description: Search and analyze your own conversation history from session log files using jq.
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq"]}}}
description: Search and analyze your own session logs (older/parent conversations) using jq.
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
---
# session-logs
Search your complete conversation history stored in session JSONL files. Use this when you need to recall something not in your memory files.
Search your complete conversation history stored in session JSONL files. Use this when a user references older/parent conversations or asks what was said before.
## Trigger
Use this skill when the user asks about prior chats, parent conversations, or historical context that isn’t in memory files.
## Location
Session logs live at: `~/.clawdbot/agents/main/sessions/`
Session logs live at: `~/.clawdbot/agents/<agentId>/sessions/` (use the `agent=<id>` value from the system prompt Runtime line).
- **`sessions.json`** - Index mapping session keys to session IDs
- **`<session-id>.jsonl`** - Full conversation transcript per session
@@ -21,14 +25,14 @@ Each `.jsonl` file contains messages with:
- `type`: "session" (metadata) or "message"
- `timestamp`: ISO timestamp
- `message.role`: "user", "assistant", or "toolResult"
- `message.content[]`: Text, thinking, or tool calls
- `message.content[]`: Text, thinking, or tool calls (filter `type=="text"` for human-readable content)
- `message.usage.cost.total`: Cost per response
## Common Queries
### List all sessions by date and size
```bash
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
size=$(ls -lh "$f" | awk '{print $5}')
echo "$date $size $(basename $f)"
@@ -37,19 +41,19 @@ done | sort -r
### Find sessions from a specific day
```bash
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
head -1 "$f" | jq -r '.timestamp' | grep -q "2026-01-06" && echo "$f"
done
```
### Extract user messages from a session
```bash
jq -r 'select(.message.role == "user") | .message.content[0].text' <session>.jsonl
jq -r 'select(.message.role == "user") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl
```
### Search for keyword in assistant responses
```bash
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | grep -i "keyword"
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | rg -i "keyword"
```
### Get total cost for a session
@@ -59,7 +63,7 @@ jq -s '[.[] | .message.usage.cost.total // 0] | add' <session>.jsonl
### Daily cost summary
```bash
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f")
echo "$date $cost"
@@ -84,7 +88,7 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' <session>.json
### Search across ALL sessions for a phrase
```bash
grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
rg -l "phrase" ~/.clawdbot/agents/<agentId>/sessions/*.jsonl
```
## Tips
@@ -93,3 +97,9 @@ grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
- Large sessions can be several MB - use `head`/`tail` for sampling
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
- Deleted sessions have `.deleted.<timestamp>` suffix
## Fast text-only hint (low noise)
```bash
jq -r 'select(.type=="message") | .message.content[]? | select(.type=="text") | .text' ~/.clawdbot/agents/<agentId>/sessions/<id>.jsonl | rg 'keyword'
```
+1
View File
@@ -102,6 +102,7 @@ export async function runCliAgent(params: {
tools: [],
contextFiles,
modelDisplay,
agentId: sessionAgentId,
});
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
+14 -11
View File
@@ -9,8 +9,8 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { runExec } from "../../process/exec.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
@@ -172,10 +172,19 @@ export function buildSystemPrompt(params: {
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
agentId?: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: params.agentId,
runtime: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
},
});
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
@@ -184,13 +193,7 @@ export function buildSystemPrompt(params: {
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
},
runtimeInfo,
toolNames: params.tools.map((tool) => tool.name),
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
+16 -16
View File
@@ -64,7 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { isTimeoutError } from "../../failover-error.js";
@@ -196,25 +196,25 @@ export async function runEmbeddedAttempt(
return level ? { level, channel: "Telegram" } : undefined;
})()
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${params.provider}/${params.modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: sessionAgentId,
runtime: {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${params.provider}/${params.modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
},
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
@@ -23,6 +23,7 @@ export function buildEmbeddedSystemPrompt(params: {
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo: {
agentId?: string;
host: string;
os: string;
arch: string;
+44
View File
@@ -0,0 +1,44 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
formatUserTime,
resolveUserTimeFormat,
resolveUserTimezone,
type ResolvedTimeFormat,
} from "./date-time.js";
export type RuntimeInfoInput = {
agentId?: string;
host: string;
os: string;
arch: string;
node: string;
model: string;
channel?: string;
capabilities?: string[];
};
export type SystemPromptRuntimeParams = {
runtimeInfo: RuntimeInfoInput;
userTimezone: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
};
export function buildSystemPromptParams(params: {
config?: ClawdbotConfig;
agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">;
}): SystemPromptRuntimeParams {
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
return {
runtimeInfo: {
agentId: params.agentId,
...params.runtime,
},
userTimezone,
userTime,
userTimeFormat,
};
}
+42 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
describe("buildAgentSystemPrompt", () => {
it("includes owner numbers when provided", () => {
@@ -252,6 +252,22 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("capabilities=inlineButtons");
});
it("includes agent id in runtime when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
runtimeInfo: {
agentId: "work",
host: "host",
os: "macOS",
arch: "arm64",
node: "v20",
model: "anthropic/claude",
},
});
expect(prompt).toContain("agent=work");
});
it("includes reasoning visibility hint", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
@@ -263,6 +279,31 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("/status shows Reasoning");
});
it("builds runtime line with agent and channel details", () => {
const line = buildRuntimeLine(
{
agentId: "work",
host: "host",
os: "macOS",
arch: "arm64",
node: "v20",
model: "anthropic/claude",
},
"telegram",
["inlineButtons"],
"low",
);
expect(line).toContain("agent=work");
expect(line).toContain("host=host");
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");
expect(line).toContain("channel=telegram");
expect(line).toContain("capabilities=inlineButtons");
expect(line).toContain("thinking=low");
});
it("describes sandboxed runtime and elevated when allowed", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
+35 -21
View File
@@ -149,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
agentId?: string;
host?: string;
os?: string;
arch?: string;
@@ -546,27 +547,40 @@ export function buildAgentSystemPrompt(params: {
);
}
lines.push(
"## Runtime",
`Runtime: ${[
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch
? `arch=${runtimeInfo.arch}`
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
: "",
`thinking=${params.defaultThinkLevel ?? "off"}`,
]
.filter(Boolean)
.join(" | ")}`,
`Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`,
);
lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
return lines.filter(Boolean).join("\n");
}
export function buildRuntimeLine(
runtimeInfo?: {
agentId?: string;
host?: string;
os?: string;
arch?: string;
node?: string;
model?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
defaultThinkLevel?: ThinkLevel,
): string {
return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch
? `arch=${runtimeInfo.arch}`
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
: "",
`thinking=${defaultThinkLevel ?? "off"}`,
]
.filter(Boolean)
.join(" | ")}`;
}
@@ -1,3 +1,4 @@
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
import { resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js";
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
@@ -5,6 +6,7 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
import { buildSystemPromptParams } from "../../agents/system-prompt-params.js";
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
@@ -87,13 +89,21 @@ async function resolveContextReport(
})();
const toolSummaries = buildToolSummaryMap(tools);
const toolNames = tools.map((t) => t.name);
const runtimeInfo = {
host: "unknown",
os: "unknown",
arch: "unknown",
node: process.version,
model: `${params.provider}/${params.model}`,
};
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.cfg,
});
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg,
agentId: sessionAgentId,
runtime: {
host: "unknown",
os: "unknown",
arch: "unknown",
node: process.version,
model: `${params.provider}/${params.model}`,
},
});
const sandboxInfo = sandboxRuntime.sandboxed
? {
enabled: true,
@@ -116,8 +126,9 @@ async function resolveContextReport(
toolNames,
toolSummaries,
modelAliasLines: [],
userTimezone: "",
userTime: "",
userTimezone,
userTime,
userTimeFormat,
contextFiles: injectedFiles,
skillsPrompt,
heartbeatPrompt: undefined,
+6
View File
@@ -73,6 +73,12 @@ describe("cli program (smoke)", () => {
expect(statusCommand).toHaveBeenCalled();
});
it("registers memory command", () => {
const program = buildProgram();
const names = program.commands.map((command) => command.name());
expect(names).toContain("memory");
});
it("runs tui without overriding timeout", async () => {
const program = buildProgram();
await program.parseAsync(["tui"], { from: "user" });
+2 -22
View File
@@ -1,17 +1,8 @@
import { Command } from "commander";
import { registerBrowserCli } from "../browser-cli.js";
import { registerConfigCli } from "../config-cli.js";
import { createProgramContext } from "./context.js";
import { registerProgramCommands } from "./command-registry.js";
import { configureProgramHelp } from "./help.js";
import { registerPreActionHooks } from "./preaction.js";
import { registerAgentCommands } from "./register.agent.js";
import { registerConfigureCommand } from "./register.configure.js";
import { registerMaintenanceCommands } from "./register.maintenance.js";
import { registerMessageCommands } from "./register.message.js";
import { registerOnboardCommand } from "./register.onboard.js";
import { registerSetupCommand } from "./register.setup.js";
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
import { registerSubCliCommands } from "./register.subclis.js";
export function buildProgram() {
const program = new Command();
@@ -21,18 +12,7 @@ export function buildProgram() {
configureProgramHelp(program, ctx);
registerPreActionHooks(program, ctx.programVersion);
registerSetupCommand(program);
registerOnboardCommand(program);
registerConfigureCommand(program);
registerConfigCli(program);
registerMaintenanceCommands(program);
registerMessageCommands(program, ctx);
registerAgentCommands(program, {
agentChannelOptions: ctx.agentChannelOptions,
});
registerSubCliCommands(program, argv);
registerStatusHealthSessionsCommands(program);
registerBrowserCli(program);
registerProgramCommands(program, ctx, argv);
return program;
}
+185
View File
@@ -0,0 +1,185 @@
import type { Command } from "commander";
import { agentsListCommand } from "../../commands/agents.js";
import { healthCommand } from "../../commands/health.js";
import { sessionsCommand } from "../../commands/sessions.js";
import { statusCommand } from "../../commands/status.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import {
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
} from "../argv.js";
import { registerBrowserCli } from "../browser-cli.js";
import { registerConfigCli } from "../config-cli.js";
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
import { registerAgentCommands } from "./register.agent.js";
import { registerConfigureCommand } from "./register.configure.js";
import { registerMaintenanceCommands } from "./register.maintenance.js";
import { registerMessageCommands } from "./register.message.js";
import { registerOnboardCommand } from "./register.onboard.js";
import { registerSetupCommand } from "./register.setup.js";
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
import { registerSubCliCommands } from "./register.subclis.js";
import type { ProgramContext } from "./context.js";
type CommandRegisterParams = {
program: Command;
ctx: ProgramContext;
argv: string[];
};
type RouteSpec = {
match: (path: string[]) => boolean;
loadPlugins?: boolean;
run: (argv: string[]) => Promise<boolean>;
};
export type CommandRegistration = {
id: string;
register: (params: CommandRegisterParams) => void;
routes?: RouteSpec[];
};
const routeHealth: RouteSpec = {
match: (path) => path[0] === "health",
loadPlugins: true,
run: async (argv) => {
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true;
},
};
const routeStatus: RouteSpec = {
match: (path) => path[0] === "status",
loadPlugins: true,
run: async (argv) => {
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const all = hasFlag(argv, "--all");
const usage = hasFlag(argv, "--usage");
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true;
},
};
const routeSessions: RouteSpec = {
match: (path) => path[0] === "sessions",
run: async (argv) => {
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store");
if (store === null) return false;
const active = getFlagValue(argv, "--active");
if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime);
return true;
},
};
const routeAgentsList: RouteSpec = {
match: (path) => path[0] === "agents" && path[1] === "list",
run: async (argv) => {
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime);
return true;
},
};
const routeMemoryStatus: RouteSpec = {
match: (path) => path[0] === "memory" && path[1] === "status",
run: async (argv) => {
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const index = hasFlag(argv, "--index");
const verbose = hasFlag(argv, "--verbose");
await runMemoryStatus({ agent, json, deep, index, verbose });
return true;
},
};
export const commandRegistry: CommandRegistration[] = [
{
id: "setup",
register: ({ program }) => registerSetupCommand(program),
},
{
id: "onboard",
register: ({ program }) => registerOnboardCommand(program),
},
{
id: "configure",
register: ({ program }) => registerConfigureCommand(program),
},
{
id: "config",
register: ({ program }) => registerConfigCli(program),
},
{
id: "maintenance",
register: ({ program }) => registerMaintenanceCommands(program),
},
{
id: "message",
register: ({ program, ctx }) => registerMessageCommands(program, ctx),
},
{
id: "memory",
register: ({ program }) => registerMemoryCli(program),
routes: [routeMemoryStatus],
},
{
id: "agent",
register: ({ program, ctx }) =>
registerAgentCommands(program, { agentChannelOptions: ctx.agentChannelOptions }),
routes: [routeAgentsList],
},
{
id: "subclis",
register: ({ program, argv }) => registerSubCliCommands(program, argv),
},
{
id: "status-health-sessions",
register: ({ program }) => registerStatusHealthSessionsCommands(program),
routes: [routeHealth, routeStatus, routeSessions],
},
{
id: "browser",
register: ({ program }) => registerBrowserCli(program),
},
];
export function registerProgramCommands(
program: Command,
ctx: ProgramContext,
argv: string[] = process.argv,
) {
for (const entry of commandRegistry) {
entry.register({ program, ctx, argv });
}
}
export function findRoutedCommand(path: string[]): RouteSpec | null {
for (const entry of commandRegistry) {
if (!entry.routes) continue;
for (const route of entry.routes) {
if (route.match(path)) return route;
}
}
return null;
}
+7 -75
View File
@@ -1,23 +1,11 @@
import { defaultRuntime } from "../runtime.js";
import { setVerbose } from "../globals.js";
import { healthCommand } from "../commands/health.js";
import { statusCommand } from "../commands/status.js";
import { sessionsCommand } from "../commands/sessions.js";
import { agentsListCommand } from "../commands/agents.js";
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { emitCliBanner } from "./banner.js";
import { VERSION } from "../version.js";
import {
getCommandPath,
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
hasHelpOrVersion,
} from "./argv.js";
import { getCommandPath, hasHelpOrVersion } from "./argv.js";
import { ensureConfigReady } from "./program/config-guard.js";
import { runMemoryStatus } from "./memory-cli.js";
import { findRoutedCommand } from "./program/command-registry.js";
async function prepareRoutedCommand(params: {
argv: string[];
@@ -36,65 +24,9 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
if (hasHelpOrVersion(argv)) return false;
const path = getCommandPath(argv, 2);
const [primary, secondary] = path;
if (!primary) return false;
if (primary === "health") {
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "status") {
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const all = hasFlag(argv, "--all");
const usage = hasFlag(argv, "--usage");
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "sessions") {
await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store");
if (store === null) return false;
const active = getFlagValue(argv, "--active");
if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime);
return true;
}
if (primary === "agents" && secondary === "list") {
await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime);
return true;
}
if (primary === "memory" && secondary === "status") {
await prepareRoutedCommand({ argv, commandPath: path });
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const index = hasFlag(argv, "--index");
const verbose = hasFlag(argv, "--verbose");
await runMemoryStatus({ agent, json, deep, index, verbose });
return true;
}
return false;
if (!path[0]) return false;
const route = findRoutedCommand(path);
if (!route) return false;
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: route.loadPlugins });
return route.run(argv);
}
@@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => {
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:main"]?.sessionId).toBe("b");
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
expect(store["+1555"]).toBeUndefined();
expect(store["+1666"]).toBeUndefined();
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
@@ -229,4 +233,73 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:work"]?.sessionId).toBe("b");
expect(store["agent:main:main"]).toBeUndefined();
});
it("canonicalizes legacy main keys inside the target sessions store", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
main: { sessionId: "legacy", updatedAt: 10 },
"agent:main:main": { sessionId: "fresh", updatedAt: 20 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("fresh");
});
it("prefers the newest entry when collapsing main aliases", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
"agent:main:main": { sessionId: "legacy", updatedAt: 50 },
"agent:main:work": { sessionId: "canonical", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:work"]?.sessionId).toBe("legacy");
expect(store["agent:main:main"]).toBeUndefined();
});
it("auto-migrates when only target sessions contain legacy keys", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
main: { sessionId: "legacy", updatedAt: 10 },
});
const log = { info: vi.fn(), warn: vi.fn() };
const result = await autoMigrateLegacyState({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
log,
});
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(result.migrated).toBe(true);
expect(log.info).toHaveBeenCalled();
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
});
});
@@ -295,6 +295,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -303,6 +304,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
targetScope: undefined,
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
legacyKeys: [],
},
agentDir: {
legacyDir: "/tmp/state/agent",
+157 -20
View File
@@ -6,6 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
@@ -14,6 +15,7 @@ import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
import {
ensureDir,
existsDir,
@@ -27,6 +29,7 @@ import {
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
targetScope?: SessionScope;
stateDir: string;
oauthDir: string;
sessions: {
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
legacyKeys: string[];
};
agentDir: {
legacyDir: string;
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
return false;
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
function canonicalizeSessionKeyForAgent(params: {
key: string;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string {
const agentId = normalizeAgentId(params.agentId);
const raw = params.key.trim();
if (!raw) return raw;
if (raw === "global" || raw === "unknown") return raw;
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
agentId,
sessionKey: raw,
});
if (canonicalMain !== raw) return canonicalMain;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
return `agent:${agentId}:subagent:${rest}`;
}
if (raw.startsWith("group:")) {
const id = raw.slice("group:".length).trim();
if (!id) return raw;
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
return `agent:${normalizeAgentId(agentId)}:${channel}:group:${id}`;
return `agent:${agentId}:${channel}:group:${id}`;
}
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
return `agent:${agentId}:whatsapp:group:${raw}`;
}
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
const remainder = raw.slice("whatsapp:".length).trim();
const cleaned = remainder.replace(/^group:/i, "").trim();
if (cleaned && !isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
return `agent:${agentId}:whatsapp:group:${cleaned}`;
}
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
return `agent:${agentId}:${raw}`;
}
return raw;
return `agent:${agentId}:${raw}`;
}
function pickLatestLegacyDirectEntry(
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
return normalized;
}
function resolveUpdatedAt(entry: SessionEntryLike): number {
return typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0;
}
function mergeSessionEntry(params: {
existing: SessionEntryLike | undefined;
incoming: SessionEntryLike;
preferIncomingOnTie?: boolean;
}): SessionEntryLike {
if (!params.existing) return params.incoming;
const existingUpdated = resolveUpdatedAt(params.existing);
const incomingUpdated = resolveUpdatedAt(params.incoming);
if (incomingUpdated > existingUpdated) return params.incoming;
if (incomingUpdated < existingUpdated) return params.existing;
return params.preferIncomingOnTie ? params.incoming : params.existing;
}
function canonicalizeSessionStore(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): { store: Record<string, SessionEntryLike>; legacyKeys: string[] } {
const canonical: Record<string, SessionEntryLike> = {};
const meta = new Map<string, { isCanonical: boolean; updatedAt: number }>();
const legacyKeys: string[] = [];
for (const [key, entry] of Object.entries(params.store)) {
if (!entry || typeof entry !== "object") continue;
const canonicalKey = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
const isCanonical = canonicalKey === key;
if (!isCanonical) legacyKeys.push(key);
const existing = canonical[canonicalKey];
if (!existing) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: resolveUpdatedAt(entry) });
continue;
}
const existingMeta = meta.get(canonicalKey);
const incomingUpdated = resolveUpdatedAt(entry);
const existingUpdated = existingMeta?.updatedAt ?? resolveUpdatedAt(existing);
if (incomingUpdated > existingUpdated) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
if (incomingUpdated < existingUpdated) continue;
if (existingMeta?.isCanonical && !isCanonical) continue;
if (!existingMeta?.isCanonical && isCanonical) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
}
return { store: canonical, legacyKeys };
}
function listLegacySessionKeys(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string[] {
const legacy: string[] = [];
for (const key of Object.keys(params.store)) {
const canonical = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
if (canonical !== key) legacy.push(key);
}
return legacy;
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const targetScope = params.cfg.session?.scope;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const targetSessionParsed = fileExists(sessionsTargetStorePath)
? readSessionStoreJson5(sessionsTargetStorePath)
: { store: {}, ok: true };
const legacyKeys = targetSessionParsed.ok
? listLegacySessionKeys({
store: targetSessionParsed.store,
agentId: targetAgentId,
mainKey: targetMainKey,
scope: targetScope,
})
: [];
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
}
if (legacyKeys.length > 0) {
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
}
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
return {
targetAgentId,
targetMainKey,
targetScope,
stateDir,
oauthDir,
sessions: {
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
legacyKeys,
},
agentDir: {
legacyDir: legacyAgentDir,
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
const legacyStore = legacyParsed.store;
const targetStore = targetParsed.store;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const canonicalizedTarget = canonicalizeSessionStore({
store: targetStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const canonicalizedLegacy = canonicalizeSessionStore({
store: legacyStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
merged[key] = mergeSessionEntry({
existing: merged[key],
incoming: entry,
preferIncomingOnTie: false,
});
}
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
}
if (
legacyParsed.ok &&
(legacyParsed.ok || targetParsed.ok) &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
if (canonicalizedTarget.legacyKeys.length > 0) {
changes.push(
`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`,
);
}
}
const entries = safeReadDir(detected.sessions.legacyDir);
@@ -158,7 +158,7 @@ describe("SearchableSelectList", () => {
list.handleInput("z");
const output = list.render(80);
expect(output.some((line) => line.includes("No matching"))).toBe(true);
expect(output.some((line) => line.includes("No matches"))).toBe(true);
});
it("navigates with arrow keys", () => {
+1 -1
View File
@@ -190,7 +190,7 @@ export class SearchableSelectList implements Component {
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching models"));
lines.push(this.theme.noMatch(" No matches"));
return lines;
}
+52
View File
@@ -0,0 +1,52 @@
import chalk from "chalk";
type HighlightTheme = Record<string, (text: string) => string>;
/**
* Syntax highlighting theme for code blocks.
* Uses chalk functions to style different token types.
*/
export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme {
return {
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.
type: chalk.hex("#4EC9B0"), // teal - types
literal: chalk.hex("#569CD6"), // blue - true, false, null
number: chalk.hex("#B5CEA8"), // green - numbers
string: chalk.hex("#CE9178"), // orange - strings
regexp: chalk.hex("#D16969"), // red - regex
symbol: chalk.hex("#B5CEA8"), // green - symbols
class: chalk.hex("#4EC9B0"), // teal - class names
function: chalk.hex("#DCDCAA"), // yellow - function names
title: chalk.hex("#DCDCAA"), // yellow - titles/names
params: chalk.hex("#9CDCFE"), // light blue - parameters
comment: chalk.hex("#6A9955"), // green - comments
doctag: chalk.hex("#608B4E"), // darker green - jsdoc tags
meta: chalk.hex("#9CDCFE"), // light blue - meta/preprocessor
"meta-keyword": chalk.hex("#C586C0"), // purple
"meta-string": chalk.hex("#CE9178"), // orange
section: chalk.hex("#DCDCAA"), // yellow - sections
tag: chalk.hex("#569CD6"), // blue - HTML/XML tags
name: chalk.hex("#9CDCFE"), // light blue - tag names
attr: chalk.hex("#9CDCFE"), // light blue - attributes
attribute: chalk.hex("#9CDCFE"), // light blue - attributes
variable: chalk.hex("#9CDCFE"), // light blue - variables
bullet: chalk.hex("#D7BA7D"), // gold - list bullets in markdown
code: chalk.hex("#CE9178"), // orange - inline code
emphasis: chalk.italic, // italic
strong: chalk.bold, // bold
formula: chalk.hex("#C586C0"), // purple - math
link: chalk.hex("#4EC9B0"), // teal - links
quote: chalk.hex("#6A9955"), // green - quotes
addition: chalk.hex("#B5CEA8"), // green - diff additions
deletion: chalk.hex("#F44747"), // red - diff deletions
"selector-tag": chalk.hex("#D7BA7D"), // gold - CSS selectors
"selector-id": chalk.hex("#D7BA7D"), // gold
"selector-class": chalk.hex("#D7BA7D"), // gold
"selector-attr": chalk.hex("#D7BA7D"), // gold
"selector-pseudo": chalk.hex("#D7BA7D"), // gold
"template-tag": chalk.hex("#C586C0"), // purple
"template-variable": chalk.hex("#9CDCFE"), // light blue
default: fallback, // fallback to code color
};
}
+2 -46
View File
@@ -7,6 +7,7 @@ import type {
import chalk from "chalk";
import { highlight, supportsLanguage } from "cli-highlight";
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
import { createSyntaxTheme } from "./syntax-theme.js";
const palette = {
text: "#E8E3D5",
@@ -35,52 +36,7 @@ const palette = {
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
/**
* Syntax highlighting theme for code blocks.
* Uses chalk functions to style different token types.
*/
const syntaxTheme = {
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.
type: chalk.hex("#4EC9B0"), // teal - types
literal: chalk.hex("#569CD6"), // blue - true, false, null
number: chalk.hex("#B5CEA8"), // green - numbers
string: chalk.hex("#CE9178"), // orange - strings
regexp: chalk.hex("#D16969"), // red - regex
symbol: chalk.hex("#B5CEA8"), // green - symbols
class: chalk.hex("#4EC9B0"), // teal - class names
function: chalk.hex("#DCDCAA"), // yellow - function names
title: chalk.hex("#DCDCAA"), // yellow - titles/names
params: chalk.hex("#9CDCFE"), // light blue - parameters
comment: chalk.hex("#6A9955"), // green - comments
doctag: chalk.hex("#608B4E"), // darker green - jsdoc tags
meta: chalk.hex("#9CDCFE"), // light blue - meta/preprocessor
"meta-keyword": chalk.hex("#C586C0"), // purple
"meta-string": chalk.hex("#CE9178"), // orange
section: chalk.hex("#DCDCAA"), // yellow - sections
tag: chalk.hex("#569CD6"), // blue - HTML/XML tags
name: chalk.hex("#9CDCFE"), // light blue - tag names
attr: chalk.hex("#9CDCFE"), // light blue - attributes
attribute: chalk.hex("#9CDCFE"), // light blue - attributes
variable: chalk.hex("#9CDCFE"), // light blue - variables
bullet: chalk.hex("#D7BA7D"), // gold - list bullets in markdown
code: chalk.hex("#CE9178"), // orange - inline code
emphasis: chalk.italic, // italic
strong: chalk.bold, // bold
formula: chalk.hex("#C586C0"), // purple - math
link: chalk.hex("#4EC9B0"), // teal - links
quote: chalk.hex("#6A9955"), // green - quotes
addition: chalk.hex("#B5CEA8"), // green - diff additions
deletion: chalk.hex("#F44747"), // red - diff deletions
"selector-tag": chalk.hex("#D7BA7D"), // gold - CSS selectors
"selector-id": chalk.hex("#D7BA7D"), // gold
"selector-class": chalk.hex("#D7BA7D"), // gold
"selector-attr": chalk.hex("#D7BA7D"), // gold
"selector-pseudo": chalk.hex("#D7BA7D"), // gold
"template-tag": chalk.hex("#C586C0"), // purple
"template-variable": chalk.hex("#9CDCFE"), // light blue
default: fg(palette.code), // fallback to code color
};
const syntaxTheme = createSyntaxTheme(fg(palette.code));
/**
* Highlight code with syntax coloring.
+3 -7
View File
@@ -7,11 +7,7 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import {
createSearchableSelectList,
createSelectList,
createSettingsList,
} from "./components/selectors.js";
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
@@ -117,7 +113,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === state.agentDefaultId ? "default" : "",
}));
const selector = createSelectList(items, 9);
const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();
@@ -147,7 +143,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
: formatSessionKey(session.key),
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
}));
const selector = createSelectList(items, 9);
const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();