clearclaw 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -7
- package/dist/channel/slack.d.ts +18 -1
- package/dist/channel/slack.js +227 -27
- package/dist/channel/telegram.d.ts +19 -0
- package/dist/channel/telegram.js +136 -30
- package/dist/config.d.ts +37 -1
- package/dist/config.js +118 -0
- package/dist/engine/acp.d.ts +9 -4
- package/dist/engine/acp.js +301 -29
- package/dist/engine/claude-code.d.ts +19 -2
- package/dist/engine/claude-code.js +121 -33
- package/dist/engine/mcp-http.d.ts +9 -0
- package/dist/engine/mcp-http.js +59 -0
- package/dist/engine/registry.d.ts +10 -7
- package/dist/engine/registry.js +99 -16
- package/dist/engine/session-transcript.d.ts +14 -0
- package/dist/engine/session-transcript.js +38 -0
- package/dist/format.d.ts +1 -1
- package/dist/format.js +42 -7
- package/dist/index.js +18 -23
- package/dist/orchestrator.d.ts +31 -4
- package/dist/orchestrator.js +822 -167
- package/dist/plan-usage.d.ts +6 -0
- package/dist/plan-usage.js +31 -0
- package/dist/prompt.d.ts +6 -2
- package/dist/prompt.js +62 -19
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +82 -0
- package/dist/types.d.ts +77 -5
- package/package.json +11 -4
- package/patches/README.md +13 -0
- package/patches/codex-acp-1.11.0.patch +38 -0
- package/prompts/SYSTEM.md +66 -16
- package/scripts/patch-codex-acp.mjs +32 -0
- package/prompts/ONBOARDING.md +0 -56
package/dist/orchestrator.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -5,23 +6,28 @@ import { z } from "zod";
|
|
|
5
6
|
import log from "./logger.js";
|
|
6
7
|
import { saveFile } from "./files.js";
|
|
7
8
|
import { assemblePrompt } from "./prompt.js";
|
|
9
|
+
import { formatPlanUsage, recordPlanUsage } from "./plan-usage.js";
|
|
8
10
|
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, formatTodoList, timeAgo } from "./format.js";
|
|
9
11
|
import { permissionHandlers, displayHandledTools } from "./tool-handlers.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
12
|
+
import { formatSessionRecap, formatSessionTranscript, stripPromptPrefix } from "./engine/session-transcript.js";
|
|
13
|
+
import { Scheduler } from "./scheduler.js";
|
|
13
14
|
const MODE_OPTIONS = [
|
|
14
15
|
{ label: "Default", value: "default" },
|
|
15
16
|
{ label: "Accept Edits", value: "acceptEdits" },
|
|
16
17
|
{ label: "Plan", value: "plan" },
|
|
17
18
|
{ label: "Bypass", value: "bypassPermissions" },
|
|
18
19
|
];
|
|
20
|
+
const STREAM_EDIT_LIMIT = 3000;
|
|
21
|
+
const STREAM_INITIAL_EDIT_MIN = 96;
|
|
22
|
+
const STREAM_EDIT_DELTA = 160;
|
|
19
23
|
export class Orchestrator {
|
|
20
24
|
channel;
|
|
21
25
|
engines;
|
|
22
26
|
config;
|
|
23
27
|
chats = new Map();
|
|
24
|
-
|
|
28
|
+
creatingWorkspaces = new Set();
|
|
29
|
+
scheduler = null;
|
|
30
|
+
planUsage = new Map();
|
|
25
31
|
constructor(opts) {
|
|
26
32
|
this.channel = opts.channel;
|
|
27
33
|
this.engines = opts.engines;
|
|
@@ -33,6 +39,8 @@ export class Orchestrator {
|
|
|
33
39
|
s = {
|
|
34
40
|
busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
|
|
35
41
|
toolCallHandle: null, todoHandle: null, textHandle: null, textBuffer: "",
|
|
42
|
+
textPublishedLength: 0,
|
|
43
|
+
textReplySent: false,
|
|
36
44
|
engineName: null,
|
|
37
45
|
messageQueue: [], debounceTimer: null,
|
|
38
46
|
};
|
|
@@ -49,13 +57,18 @@ export class Orchestrator {
|
|
|
49
57
|
return engine;
|
|
50
58
|
}
|
|
51
59
|
async start() {
|
|
60
|
+
this.config.ensureHomeWorkspace();
|
|
52
61
|
this.channel.on("message", (msg) => {
|
|
53
62
|
this.routeMessage(msg).catch((err) => {
|
|
54
63
|
log.error({ err }, "[orchestrator] unhandled message error");
|
|
55
64
|
});
|
|
56
65
|
});
|
|
57
66
|
await this.channel.connect();
|
|
67
|
+
for (const ws of this.config.listWorkspaces())
|
|
68
|
+
this.deliverInitialBrief(ws.name);
|
|
58
69
|
log.info("ClearClaw ready.");
|
|
70
|
+
this.scheduler = new Scheduler(this.config, (msg) => this.deliverToWorkspace("default", msg.origin, msg.text));
|
|
71
|
+
this.scheduler.start();
|
|
59
72
|
const shutdown = async () => {
|
|
60
73
|
await this.stop();
|
|
61
74
|
process.exit(0);
|
|
@@ -64,18 +77,192 @@ export class Orchestrator {
|
|
|
64
77
|
process.on("SIGTERM", shutdown);
|
|
65
78
|
}
|
|
66
79
|
async stop() {
|
|
80
|
+
this.scheduler?.stop();
|
|
67
81
|
await this.channel.disconnect();
|
|
68
82
|
}
|
|
69
|
-
/**
|
|
83
|
+
/** Deliver a synthetic message to a named workspace and trigger its turn. */
|
|
84
|
+
deliverToWorkspace(workspaceName, origin, text) {
|
|
85
|
+
const ws = this.config.workspaceByName(workspaceName);
|
|
86
|
+
if (!ws?.chat_id || !this.channel.ownsId(ws.chat_id)) {
|
|
87
|
+
log.warn("[deliver] workspace '%s' not found", workspaceName);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const msg = {
|
|
91
|
+
chatId: ws.chat_id,
|
|
92
|
+
chatType: ws.name === "default" ? "dm" : "group",
|
|
93
|
+
text,
|
|
94
|
+
origin,
|
|
95
|
+
};
|
|
96
|
+
this.enqueueMessage(msg, ws, this.chat(ws.chat_id));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Everything creation can reject without touching the platform. Runs before the
|
|
101
|
+
* confirmation, so the user is never asked to approve a doomed spawn, and again
|
|
102
|
+
* inside creation, where it is the authoritative check.
|
|
103
|
+
*/
|
|
104
|
+
validateNewWorkspace(args, newProjectName) {
|
|
105
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(args.name)) {
|
|
106
|
+
return "Use a short workspace name containing letters, numbers, hyphens, or underscores.";
|
|
107
|
+
}
|
|
108
|
+
if (this.config.workspaceByName(args.name) || this.creatingWorkspaces.has(args.name)) {
|
|
109
|
+
return `Workspace "${args.name}" already exists or is being created. Pick another name.`;
|
|
110
|
+
}
|
|
111
|
+
if (newProjectName && this.config.projectByName(newProjectName)) {
|
|
112
|
+
return `Project "${newProjectName}" already exists. Name it as project to put this peer in it.`;
|
|
113
|
+
}
|
|
114
|
+
if (!path.isAbsolute(args.cwd) || !fs.existsSync(args.cwd) || !fs.statSync(args.cwd).isDirectory()) {
|
|
115
|
+
return `cwd "${args.cwd}" must be an existing directory with an absolute path. Prepare it with your own tooling, then retry.`;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Shared creation path for standalone projects and peers, with optional manual binding. */
|
|
120
|
+
async createWorkspace(chatId, fromName, project, newProjectName, mainWs, args, runtime) {
|
|
121
|
+
const result = (text) => ({ content: [{ type: "text", text }] });
|
|
122
|
+
const invalid = this.validateNewWorkspace(args, newProjectName);
|
|
123
|
+
if (invalid)
|
|
124
|
+
return result(invalid);
|
|
125
|
+
const description = args.description ?? args.brief.split("\n")[0].slice(0, 160);
|
|
126
|
+
const destination = project ?? { name: newProjectName, main_workspace: args.name, description };
|
|
127
|
+
const anchor = mainWs.chat_id ?? chatId;
|
|
128
|
+
if (!args.manual && (!this.channel.ownsId(anchor) || !this.channel.createProjectChat || !this.channel.closeProjectChat)) {
|
|
129
|
+
return result("Automatic chat creation is unavailable for this destination. Retry with a manual chat, then use /connect <workspace> in that chat.");
|
|
130
|
+
}
|
|
131
|
+
this.creatingWorkspaces.add(args.name);
|
|
132
|
+
const cwd = args.cwd;
|
|
133
|
+
let createdChatId = null;
|
|
134
|
+
let registered = false;
|
|
135
|
+
let createdProject = false;
|
|
136
|
+
try {
|
|
137
|
+
if (!args.manual)
|
|
138
|
+
createdChatId = await this.channel.createProjectChat(destination.name, anchor, args.name);
|
|
139
|
+
// Chat creation awaits the platform. Recheck config before committing our records.
|
|
140
|
+
if (this.config.workspaceByName(args.name) || (newProjectName && this.config.projectByName(newProjectName))) {
|
|
141
|
+
throw new Error("The workspace or project name was taken during chat creation. Retry with another name.");
|
|
142
|
+
}
|
|
143
|
+
if (createdChatId && this.config.workspaceByChat(createdChatId))
|
|
144
|
+
throw new Error("The created chat is already connected to another workspace.");
|
|
145
|
+
if (project && !this.config.projectByName(project.name))
|
|
146
|
+
throw new Error("The target project was archived during chat creation.");
|
|
147
|
+
this.config.upsertWorkspace({
|
|
148
|
+
name: args.name, cwd, chat_id: createdChatId, current_session_id: null,
|
|
149
|
+
behavior: args.behavior ?? mainWs.behavior, engine: runtime.engine, model: runtime.model,
|
|
150
|
+
project: destination.name, description, spawnedFrom: fromName,
|
|
151
|
+
pending_brief: { fromWorkspace: fromName, text: args.brief },
|
|
152
|
+
});
|
|
153
|
+
registered = true;
|
|
154
|
+
if (!project) {
|
|
155
|
+
this.config.addProject(destination);
|
|
156
|
+
createdProject = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (createdProject)
|
|
161
|
+
this.config.removeProject(destination.name);
|
|
162
|
+
if (registered)
|
|
163
|
+
this.config.removeWorkspace(args.name);
|
|
164
|
+
if (createdChatId && !this.config.workspaceByChat(createdChatId)) {
|
|
165
|
+
await this.channel.closeProjectChat(createdChatId, destination.name).catch(() => { });
|
|
166
|
+
}
|
|
167
|
+
return result(`Spawn failed: ${err instanceof Error ? err.message : String(err)}. Retry with a manual chat if automatic chat creation is unavailable.`);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
this.creatingWorkspaces.delete(args.name);
|
|
171
|
+
}
|
|
172
|
+
// Once registered, notification failures must not roll back a workspace or a delivered brief.
|
|
173
|
+
const grouping = createdChatId ? await this.groupChat(destination.name, createdChatId) : undefined;
|
|
174
|
+
const delivered = this.deliverInitialBrief(args.name);
|
|
175
|
+
const note = createdChatId
|
|
176
|
+
? (delivered ? "; brief delivered." : ".")
|
|
177
|
+
: `. In the intended chat, send /connect ${args.name}.`;
|
|
178
|
+
log.info("[workspace] created %s in %s", args.name, destination.name);
|
|
179
|
+
return result(`Spawned workspace "${args.name}" at ${cwd}${note}${grouping ? `\n\n${grouping}` : ""}`);
|
|
180
|
+
}
|
|
181
|
+
/** Grouping is best-effort: it never fails a lifecycle operation, but it is never silent either. */
|
|
182
|
+
async groupChat(projectName, chatId) {
|
|
183
|
+
try {
|
|
184
|
+
return await this.channel.groupProjectChat?.(projectName, chatId);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
log.warn({ err }, "[project] grouping failed for %s", projectName);
|
|
188
|
+
return `Could not group this chat under "${projectName}": ${err instanceof Error ? err.message : String(err)}.`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
deliverInitialBrief(name) {
|
|
192
|
+
const ws = this.config.workspaceByName(name);
|
|
193
|
+
if (!ws?.chat_id || !ws.pending_brief)
|
|
194
|
+
return false;
|
|
195
|
+
// An existing or queued ordinary turn already includes the saved brief.
|
|
196
|
+
const state = this.chats.get(ws.chat_id);
|
|
197
|
+
if (state?.busy || state?.messageQueue.length)
|
|
198
|
+
return false;
|
|
199
|
+
const brief = ws.pending_brief;
|
|
200
|
+
return this.deliverToWorkspace(name, { kind: "peer", workspaceName: brief.fromWorkspace }, brief.text);
|
|
201
|
+
}
|
|
202
|
+
/** An unbound chat can connect without starting a temporary agent or sharing home's session. */
|
|
203
|
+
async connectWorkspace(msg, name) {
|
|
204
|
+
const reply = (text) => this.channel.sendMessage(msg.chatId, text);
|
|
205
|
+
if (!name) {
|
|
206
|
+
await reply("Usage: /connect <workspace>. Create a workspace with a manual chat from home first.");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const existing = this.config.workspaceByChat(msg.chatId);
|
|
210
|
+
if (existing) {
|
|
211
|
+
await reply(`This chat is already connected to "${existing.name}".`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const ws = this.config.workspaceByName(name);
|
|
215
|
+
if (!ws) {
|
|
216
|
+
await reply(`No workspace named "${name}". Create it from home with a manual chat first.`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (name === "default") {
|
|
220
|
+
await reply("Home connects automatically through an authorized private DM.");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (ws.chat_id) {
|
|
224
|
+
await reply(`Workspace "${name}" already has a chat. Existing bindings cannot be replaced by /connect.`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (this.chat(msg.chatId).busy) {
|
|
228
|
+
await reply("A turn is running. Wait for it to finish before connecting.");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.config.upsertWorkspace({ ...ws, chat_id: msg.chatId });
|
|
232
|
+
const grouping = ws.project ? await this.groupChat(ws.project, msg.chatId) : undefined;
|
|
233
|
+
this.deliverInitialBrief(name);
|
|
234
|
+
await reply(`Connected this chat to workspace "${name}".${grouping ? `\n\n${grouping}` : ""}`);
|
|
235
|
+
}
|
|
236
|
+
peerRuntime(baseWorkspace, requested) {
|
|
237
|
+
const baseEngine = baseWorkspace?.engine ?? this.config.defaultEngine;
|
|
238
|
+
const engine = requested.engine ?? baseWorkspace?.engine;
|
|
239
|
+
const effectiveEngine = engine ?? this.config.defaultEngine;
|
|
240
|
+
if (!this.engines.has(effectiveEngine)) {
|
|
241
|
+
return { error: `Unknown engine "${effectiveEngine}". Available: ${[...this.engines.keys()].join(", ")}` };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
runtime: {
|
|
245
|
+
engine,
|
|
246
|
+
model: requested.model
|
|
247
|
+
?? (effectiveEngine === baseEngine
|
|
248
|
+
? baseWorkspace?.model
|
|
249
|
+
: undefined),
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Effective behavior: Explicit setting or home→assistant / project→relay. */
|
|
70
254
|
effectiveBehavior(ctx) {
|
|
71
|
-
if (isTask(ctx))
|
|
72
|
-
return "assistant";
|
|
73
255
|
if (ctx.behavior !== undefined)
|
|
74
256
|
return ctx.behavior;
|
|
75
257
|
return ctx.cwd === this.config.homeWorkspacePath ? "assistant" : "relay";
|
|
76
258
|
}
|
|
77
|
-
/**
|
|
259
|
+
/** Permission mode a chat runs in until /mode picks one. */
|
|
260
|
+
baseMode(behavior) {
|
|
261
|
+
return behavior === "assistant" ? "bypassPermissions" : this.config.permissionMode;
|
|
262
|
+
}
|
|
263
|
+
/** Enqueue a message and drain — immediately for relay, debounced for assistant. */
|
|
78
264
|
enqueueMessage(msg, ctx, state) {
|
|
265
|
+
log.info("[msg] %s: %s", senderLabel(msg.origin), msg.text.slice(0, 80));
|
|
79
266
|
state.messageQueue.push(msg);
|
|
80
267
|
if (state.busy)
|
|
81
268
|
return;
|
|
@@ -103,7 +290,7 @@ export class Orchestrator {
|
|
|
103
290
|
const state = this.chat(chatId);
|
|
104
291
|
if (state.messageQueue.length === 0 || state.busy)
|
|
105
292
|
return;
|
|
106
|
-
const ctx = this.
|
|
293
|
+
const ctx = this.config.workspaceByChat(chatId);
|
|
107
294
|
if (!ctx)
|
|
108
295
|
return;
|
|
109
296
|
const messages = [...state.messageQueue];
|
|
@@ -113,6 +300,11 @@ export class Orchestrator {
|
|
|
113
300
|
}
|
|
114
301
|
catch (err) {
|
|
115
302
|
log.error({ err }, "[fatal]");
|
|
303
|
+
// executeTurn marks the chat busy before its own finally is in scope; without this,
|
|
304
|
+
// an early throw leaves the chat wedged until restart.
|
|
305
|
+
state.busy = false;
|
|
306
|
+
state.abort = null;
|
|
307
|
+
await this.channel.setTyping(chatId, false).catch(() => { });
|
|
116
308
|
await this.channel.sendMessage(chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
117
309
|
return;
|
|
118
310
|
}
|
|
@@ -129,30 +321,76 @@ export class Orchestrator {
|
|
|
129
321
|
}
|
|
130
322
|
}
|
|
131
323
|
async executeTurn(chatId, messages, ctx, state) {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
324
|
+
const ws = ctx;
|
|
325
|
+
const initialBrief = slashCommandPrompt(messages) ? undefined : ws.pending_brief;
|
|
326
|
+
if (initialBrief && !messages.some((m) => m.origin.kind === "peer"
|
|
327
|
+
&& m.origin.workspaceName === initialBrief.fromWorkspace && m.text === initialBrief.text)) {
|
|
328
|
+
messages = [{ chatId, chatType: "group", origin: { kind: "peer", workspaceName: initialBrief.fromWorkspace }, text: initialBrief.text }, ...messages];
|
|
329
|
+
}
|
|
330
|
+
const turnCwd = ws.cwd;
|
|
331
|
+
if (!fs.existsSync(turnCwd)) {
|
|
332
|
+
log.warn("[turn] aborting: cwd does not exist: %s", turnCwd);
|
|
333
|
+
await this.channel.sendMessage(chatId, `⚠️ Can't start: this workspace's directory doesn't exist:\n${turnCwd}\nLikely an un-created worktree. Create it (e.g. \`git worktree add "${turnCwd}" -b <branch>\`) or fix the workspace cwd, then retry.`).catch(() => { });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
134
336
|
state.busy = true;
|
|
135
337
|
const abort = new AbortController();
|
|
136
338
|
state.abort = abort;
|
|
137
|
-
const behavior =
|
|
339
|
+
const behavior = messages.some((m) => m.origin.kind === "scheduler")
|
|
340
|
+
? "assistant"
|
|
341
|
+
: this.effectiveBehavior(ctx);
|
|
138
342
|
const turnState = { staySilent: false, replyToMessageId: null };
|
|
139
|
-
const sessionId =
|
|
140
|
-
const cwd =
|
|
141
|
-
const logPrefix =
|
|
343
|
+
const sessionId = ws.current_session_id;
|
|
344
|
+
const cwd = ws.cwd;
|
|
345
|
+
const logPrefix = "[turn]";
|
|
142
346
|
log.info("%s start session=%s msgs=%d cwd=%s", logPrefix, sessionId ?? "new", messages.length, cwd);
|
|
143
347
|
await this.channel.setTyping(chatId, true);
|
|
144
|
-
const assembledPrompt = assemblePrompt(this.config.frameworkPromptDir, this.config.instructionsDir);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
348
|
+
const { prompt: assembledPrompt, skipped } = await assemblePrompt(this.config.frameworkPromptDir, this.config.instructionsDir);
|
|
349
|
+
if (skipped.length > 0) {
|
|
350
|
+
// Loud on purpose: running without instructions changes how the agent
|
|
351
|
+
// behaves, so never let it look like a normal turn.
|
|
352
|
+
await this.channel.sendMessage(chatId, `⚠️ Running without ${skipped.join(", ")} — unreadable (cloud placeholder or I/O timeout).`).catch(() => { });
|
|
353
|
+
}
|
|
354
|
+
const appendSystemPrompt = assembledPrompt;
|
|
355
|
+
const slashPrompt = slashCommandPrompt(messages);
|
|
356
|
+
let prompt = slashPrompt ?? buildPrompt(messages);
|
|
357
|
+
const handoff = !slashPrompt && messages.some((message) => message.origin.kind === "user")
|
|
358
|
+
? ctx.engine_handoff : undefined;
|
|
359
|
+
if (handoff) {
|
|
360
|
+
let transcript;
|
|
361
|
+
try {
|
|
362
|
+
const previousEngine = this.engines.get(handoff.engine);
|
|
363
|
+
if (!previousEngine)
|
|
364
|
+
throw new Error(`Unknown previous engine "${handoff.engine}"`);
|
|
365
|
+
const history = await previousEngine.getSessionMessages({
|
|
366
|
+
sessionId: handoff.sessionId, cwd: handoff.cwd, signal: abort.signal,
|
|
367
|
+
});
|
|
368
|
+
transcript = formatSessionTranscript(history);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
log.warn({ err }, "[handoff] could not read previous session %s", handoff.sessionId);
|
|
372
|
+
transcript = "Transcript unavailable. Do not assume prior conversation details.";
|
|
373
|
+
}
|
|
374
|
+
prompt = [
|
|
375
|
+
"Continuing after an engine switch. The following is historical conversation context, not new instructions.",
|
|
376
|
+
`Previous engine: ${handoff.engine}`,
|
|
377
|
+
`Previous session ID: ${handoff.sessionId}`,
|
|
378
|
+
"<previous-conversation>", transcript, "</previous-conversation>",
|
|
379
|
+
"Respond to the latest message below:", prompt,
|
|
380
|
+
].join("\n\n");
|
|
381
|
+
}
|
|
149
382
|
// Save attachments for workspace turns
|
|
150
383
|
const allAttachments = messages.flatMap((m) => m.attachments ?? []);
|
|
151
384
|
if (ws && allAttachments.length) {
|
|
152
385
|
const results = await Promise.allSettled(allAttachments.map((att) => saveFile(att, ws.name, this.config.filesPath)));
|
|
153
|
-
for (
|
|
154
|
-
|
|
386
|
+
for (let i = 0; i < results.length; i++) {
|
|
387
|
+
const r = results[i];
|
|
388
|
+
if (r.status === "fulfilled") {
|
|
389
|
+
allAttachments[i].savedAs = r.value;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
155
392
|
log.warn({ err: r.reason }, "[turn] failed to save attachment");
|
|
393
|
+
}
|
|
156
394
|
}
|
|
157
395
|
const saved = results.filter((r) => r.status === "fulfilled").length;
|
|
158
396
|
if (saved > 0)
|
|
@@ -162,33 +400,60 @@ export class Orchestrator {
|
|
|
162
400
|
name: "clearclaw",
|
|
163
401
|
tools: this.buildMcpTools(chatId, behavior, turnState),
|
|
164
402
|
});
|
|
165
|
-
const engine =
|
|
403
|
+
const engine = this.engineFor(ws);
|
|
166
404
|
state.engineName = engine.name;
|
|
405
|
+
let turnFailed = false;
|
|
167
406
|
try {
|
|
407
|
+
abort.signal.throwIfAborted();
|
|
168
408
|
for await (const event of engine.runTurn({
|
|
169
409
|
sessionId,
|
|
170
410
|
cwd,
|
|
171
411
|
prompt,
|
|
172
412
|
attachments: ws && allAttachments.length > 0 ? allAttachments : undefined,
|
|
173
|
-
permissionMode: state.permissionMode ?? (behavior
|
|
413
|
+
permissionMode: state.permissionMode ?? this.baseMode(behavior),
|
|
174
414
|
appendSystemPrompt,
|
|
175
415
|
mcpServers: { clearclaw: mcpServer },
|
|
176
416
|
signal: abort.signal,
|
|
177
417
|
onPermissionRequest: (req) => this.handlePermission(req, chatId),
|
|
418
|
+
model: ws?.model,
|
|
178
419
|
})) {
|
|
179
|
-
|
|
420
|
+
if (event.type === "plan_usage") {
|
|
421
|
+
let usage = this.planUsage.get(engine.name);
|
|
422
|
+
if (!usage) {
|
|
423
|
+
usage = { windows: new Map(engine.planUsageWindows?.map((window) => [window.id, window])) };
|
|
424
|
+
this.planUsage.set(engine.name, usage);
|
|
425
|
+
}
|
|
426
|
+
recordPlanUsage(usage, event);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (event.type === "error")
|
|
430
|
+
turnFailed = true;
|
|
431
|
+
// Persist the session as soon as the engine reports it —
|
|
432
|
+
// so cancelling mid-turn doesn't lose it.
|
|
433
|
+
if (event.type === "session") {
|
|
434
|
+
this.persistSessionId(ws, event.sessionId);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
// Persist completion only while the workspace still belongs to this turn.
|
|
180
438
|
if (event.type === "done") {
|
|
181
439
|
log.info("%s done session=%s", logPrefix, event.sessionId);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
440
|
+
// Observed model/usage belongs in stats, never in the model override.
|
|
441
|
+
const persistedSessionId = this.persistSessionId(ws, event.sessionId);
|
|
442
|
+
if (persistedSessionId !== undefined && event.stats)
|
|
443
|
+
state.stats = event.stats;
|
|
444
|
+
if (initialBrief && persistedSessionId !== undefined && !turnFailed && !abort.signal.aborted) {
|
|
445
|
+
const current = this.config.workspaceByName(ws.name);
|
|
446
|
+
if (current?.pending_brief?.text === initialBrief.text
|
|
447
|
+
&& current.pending_brief.fromWorkspace === initialBrief.fromWorkspace) {
|
|
448
|
+
this.config.upsertWorkspace({ ...current, pending_brief: undefined });
|
|
449
|
+
}
|
|
186
450
|
}
|
|
187
|
-
|
|
188
|
-
this.config.
|
|
451
|
+
if (handoff && persistedSessionId !== undefined && !turnFailed && !abort.signal.aborted) {
|
|
452
|
+
const current = this.config.workspaceByName(ws.name);
|
|
453
|
+
if (current && sameHandoff(current.engine_handoff, handoff)) {
|
|
454
|
+
this.config.upsertWorkspace({ ...current, engine_handoff: undefined });
|
|
455
|
+
}
|
|
189
456
|
}
|
|
190
|
-
if (event.stats)
|
|
191
|
-
state.stats = event.stats;
|
|
192
457
|
if (behavior === "relay" && state.toolCallHandle && event.stats) {
|
|
193
458
|
const summary = formatToolCallSummary(event.stats.toolCalls);
|
|
194
459
|
if (summary) {
|
|
@@ -200,15 +465,11 @@ export class Orchestrator {
|
|
|
200
465
|
}
|
|
201
466
|
state.toolCallHandle = null;
|
|
202
467
|
state.todoHandle = null;
|
|
203
|
-
|
|
204
|
-
if (state.textHandle && state.textBuffer) {
|
|
205
|
-
try {
|
|
206
|
-
await this.channel.editMessage(chatId, state.textHandle, state.textBuffer);
|
|
207
|
-
}
|
|
208
|
-
catch { /* best effort */ }
|
|
209
|
-
}
|
|
468
|
+
await this.flushTextSegment(chatId, state, turnState);
|
|
210
469
|
state.textHandle = null;
|
|
211
470
|
state.textBuffer = "";
|
|
471
|
+
state.textPublishedLength = 0;
|
|
472
|
+
state.textReplySent = false;
|
|
212
473
|
await this.updateStatusMessage(chatId, state);
|
|
213
474
|
break;
|
|
214
475
|
}
|
|
@@ -222,27 +483,81 @@ export class Orchestrator {
|
|
|
222
483
|
if (!turnState.staySilent) {
|
|
223
484
|
await this.channel.setTyping(chatId, false);
|
|
224
485
|
}
|
|
225
|
-
|
|
226
|
-
if (cancelled && !task) {
|
|
486
|
+
if (cancelled) {
|
|
227
487
|
await this.channel.sendMessage(chatId, "Turn cancelled.");
|
|
228
488
|
}
|
|
229
489
|
}
|
|
230
490
|
}
|
|
491
|
+
persistSessionId(ws, sessionId) {
|
|
492
|
+
const current = this.config.workspaceByName(ws.name);
|
|
493
|
+
// Ignore late events after an archive, rebind, or engine switch.
|
|
494
|
+
if (!current || current.chat_id !== ws.chat_id
|
|
495
|
+
|| (current.engine ?? this.config.defaultEngine) !== (ws.engine ?? this.config.defaultEngine))
|
|
496
|
+
return undefined;
|
|
497
|
+
this.config.setSession(ws.name, sessionId);
|
|
498
|
+
return sessionId;
|
|
499
|
+
}
|
|
500
|
+
/** Native selection works even when the workspace's engine cannot run. */
|
|
501
|
+
async selectEngine(msg, requested) {
|
|
502
|
+
const state = this.chat(msg.chatId);
|
|
503
|
+
const ws = this.config.workspaceByChat(msg.chatId);
|
|
504
|
+
if (!ws) {
|
|
505
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat. Use /connect <workspace> first.");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (state.busy) {
|
|
509
|
+
await this.channel.sendMessage(msg.chatId, "A turn is running. Use /cancel first, then /engine once it stops.");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const current = ws.engine ?? this.config.defaultEngine;
|
|
513
|
+
const selected = requested ?? (await this.channel.sendInteractive(msg.chatId, `Current engine: ${current}`, [[...this.engines.keys()].map((name) => ({ label: name === current ? `✓ ${name}` : name, value: name }))])).value;
|
|
514
|
+
if (!selected)
|
|
515
|
+
return;
|
|
516
|
+
if (!this.engines.has(selected)) {
|
|
517
|
+
await this.channel.sendMessage(msg.chatId, `Unknown engine "${selected}". Available: ${[...this.engines.keys()].join(", ")}`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (state.busy) {
|
|
521
|
+
await this.channel.sendMessage(msg.chatId, "A turn is running. Use /cancel first, then /engine once it stops.");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const latest = this.config.workspaceByChat(msg.chatId);
|
|
525
|
+
if (!latest || latest.name !== ws.name || (latest.engine ?? this.config.defaultEngine) !== current) {
|
|
526
|
+
await this.channel.sendMessage(msg.chatId, "Workspace or engine changed while choosing. Run /engine again.");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const changed = selected !== current;
|
|
530
|
+
if (changed) {
|
|
531
|
+
this.config.upsertWorkspace({ ...latest, engine: selected, current_session_id: null, model: undefined,
|
|
532
|
+
engine_handoff: captureHandoff(latest, current) });
|
|
533
|
+
state.stats = null;
|
|
534
|
+
}
|
|
535
|
+
state.engineName = selected;
|
|
536
|
+
const handoffNote = this.config.workspaceByChat(msg.chatId)?.engine_handoff
|
|
537
|
+
? " Previous session context will be included with your next message." : "";
|
|
538
|
+
log.info("[cmd] chat %s engine → %s", msg.chatId, selected);
|
|
539
|
+
await this.channel.sendMessage(msg.chatId, `Engine set to ${selected}.${changed ? " Session cleared." : ""}${handoffNote}`);
|
|
540
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
541
|
+
}
|
|
231
542
|
async routeMessage(msg) {
|
|
232
543
|
try {
|
|
233
|
-
|
|
544
|
+
// routeMessage only handles channel-emitted messages, which are always user-originated.
|
|
545
|
+
if (msg.origin.kind !== "user")
|
|
546
|
+
return; // unreachable; documents + type-narrows the invariant
|
|
547
|
+
const { user } = msg.origin;
|
|
548
|
+
if (!this.config.isAuthorized(user.id))
|
|
549
|
+
return;
|
|
550
|
+
if (this.channel.isRootDM(msg.chatId, user.id) && !this.config.workspaceByChat(msg.chatId)) {
|
|
551
|
+
this.config.connectHomeWorkspace(msg.chatId);
|
|
552
|
+
}
|
|
553
|
+
const connect = msg.text.match(/^\/connect(?:\s+(\S+))?$/);
|
|
554
|
+
if (connect) {
|
|
555
|
+
await this.connectWorkspace(msg, connect[1]);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
234
558
|
const state = this.chat(msg.chatId);
|
|
235
|
-
//
|
|
559
|
+
// Native controls run without a model call.
|
|
236
560
|
if (msg.text === "/cancel") {
|
|
237
|
-
const task = this.tasks.get(msg.chatId);
|
|
238
|
-
if (task) {
|
|
239
|
-
this.tasks.delete(msg.chatId);
|
|
240
|
-
if (state.abort)
|
|
241
|
-
state.abort.abort();
|
|
242
|
-
log.info("[cmd] task cancelled for chat %s", msg.chatId);
|
|
243
|
-
await this.channel.sendMessage(msg.chatId, "Setup cancelled.");
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
561
|
if (state.abort) {
|
|
247
562
|
state.abort.abort();
|
|
248
563
|
log.info("[cmd] turn cancelled");
|
|
@@ -252,16 +567,10 @@ export class Orchestrator {
|
|
|
252
567
|
}
|
|
253
568
|
return;
|
|
254
569
|
}
|
|
255
|
-
// Task routing — takes priority over workspace and all other commands
|
|
256
|
-
const existingTask = this.tasks.get(msg.chatId);
|
|
257
|
-
if (existingTask) {
|
|
258
|
-
this.enqueueMessage(msg, existingTask, state);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
570
|
const ws = this.config.workspaceByChat(msg.chatId);
|
|
262
571
|
// /mode — switch permission mode (works even during active turns)
|
|
263
572
|
if (msg.text === "/mode") {
|
|
264
|
-
const currentMode = state.permissionMode ?? this.
|
|
573
|
+
const currentMode = state.permissionMode ?? this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
|
|
265
574
|
const buttons = [
|
|
266
575
|
MODE_OPTIONS.slice(0, 2).map((opt) => ({
|
|
267
576
|
label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
|
|
@@ -289,9 +598,38 @@ export class Orchestrator {
|
|
|
289
598
|
this.config.clearSession(ws.name);
|
|
290
599
|
state.permissionMode = null;
|
|
291
600
|
state.stats = null;
|
|
292
|
-
await this.updateStatusMessage(msg.chatId, state);
|
|
293
601
|
log.info("[cmd] session cleared for workspace %s", ws.name);
|
|
294
602
|
await this.channel.sendMessage(msg.chatId, "Session cleared.");
|
|
603
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// /recap — replay current session history without running a model turn.
|
|
607
|
+
if (msg.text === "/recap") {
|
|
608
|
+
if (!ws) {
|
|
609
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.", { consumeTyping: false });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const sessionId = ws.current_session_id;
|
|
613
|
+
if (!sessionId) {
|
|
614
|
+
await this.channel.sendMessage(msg.chatId, "No current session to recap yet.", { consumeTyping: false });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const history = await this.engineFor(ws).getSessionMessages({
|
|
619
|
+
sessionId, cwd: ws.cwd, signal: AbortSignal.timeout(30_000),
|
|
620
|
+
});
|
|
621
|
+
const current = this.config.workspaceByChat(msg.chatId);
|
|
622
|
+
if (!current || current.name !== ws.name || current.current_session_id !== sessionId
|
|
623
|
+
|| current.engine !== ws.engine) {
|
|
624
|
+
await this.channel.sendMessage(msg.chatId, "Session changed while loading history. Run /recap again.", { consumeTyping: false });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
await this.channel.sendMessage(msg.chatId, formatSessionRecap(history), { consumeTyping: false });
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
log.warn({ err }, "[recap] failed to load history for workspace %s", ws.name);
|
|
631
|
+
await this.channel.sendMessage(msg.chatId, "Couldn’t load this session’s history. Try /recap again.", { consumeTyping: false });
|
|
632
|
+
}
|
|
295
633
|
return;
|
|
296
634
|
}
|
|
297
635
|
// /resume — switch to a previous session
|
|
@@ -310,15 +648,16 @@ export class Orchestrator {
|
|
|
310
648
|
return;
|
|
311
649
|
}
|
|
312
650
|
const MAX_BTN = 45;
|
|
313
|
-
//
|
|
651
|
+
// SDK summaries can contain the session's latest prompt, including
|
|
652
|
+
// ClearClaw's bracketed framing. Drop that framing for display.
|
|
314
653
|
const stripped = sessions.map((s) => ({
|
|
315
654
|
...s,
|
|
316
|
-
summary: s.summary
|
|
655
|
+
summary: stripPromptPrefix(s.summary),
|
|
317
656
|
}));
|
|
318
657
|
// Build detailed list for message body
|
|
319
658
|
const listing = stripped.map((s, i) => {
|
|
320
659
|
const current = s.sessionId === ws.current_session_id ? " ✅" : "";
|
|
321
|
-
const meta = [timeAgo(s.lastModified), s.gitBranch].filter(Boolean).join(" · ");
|
|
660
|
+
const meta = [s.lastModified ? timeAgo(s.lastModified) : "Unknown activity", s.gitBranch].filter(Boolean).join(" · ");
|
|
322
661
|
return `${i + 1}. ${s.summary}${current}\n ${meta}`;
|
|
323
662
|
}).join("\n");
|
|
324
663
|
// Concise button labels (single line)
|
|
@@ -331,7 +670,9 @@ export class Orchestrator {
|
|
|
331
670
|
});
|
|
332
671
|
const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
|
|
333
672
|
if (resp.value) {
|
|
334
|
-
this.config.
|
|
673
|
+
const current = this.config.workspaceByName(ws.name);
|
|
674
|
+
if (current)
|
|
675
|
+
this.config.upsertWorkspace({ ...current, current_session_id: resp.value, engine_handoff: undefined });
|
|
335
676
|
const picked = stripped.find((s) => s.sessionId === resp.value);
|
|
336
677
|
await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
|
|
337
678
|
log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
|
|
@@ -358,27 +699,41 @@ export class Orchestrator {
|
|
|
358
699
|
}
|
|
359
700
|
return;
|
|
360
701
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
prompt: [
|
|
368
|
-
"THIS IS A TASK SESSION — not a regular conversation.",
|
|
369
|
-
"Do NOT follow the 'Every Session' startup routine. Do NOT read MEMORY.md or daily notes. Do NOT greet the user.",
|
|
370
|
-
`This is a ${chatType} chat. Home workspace path: ${this.config.homeWorkspacePath}`,
|
|
371
|
-
"Follow the Workspace Onboarding instructions in the system prompt.",
|
|
372
|
-
].join("\n"),
|
|
373
|
-
};
|
|
374
|
-
this.tasks.set(msg.chatId, newTask);
|
|
375
|
-
log.info("[task] onboarding started for chat %s", msg.chatId);
|
|
376
|
-
this.enqueueMessage(msg, newTask, state);
|
|
702
|
+
// /engine [name] — select the workspace engine.
|
|
703
|
+
const engineMatch = msg.text.match(/^\/engine(?:\s+(\S+))?$/);
|
|
704
|
+
if (engineMatch) {
|
|
705
|
+
if (!this.config.isAuthorized(user.id)) {
|
|
706
|
+
await this.channel.sendMessage(msg.chatId, "Not authorized to select an engine.");
|
|
707
|
+
return;
|
|
377
708
|
}
|
|
378
|
-
|
|
379
|
-
|
|
709
|
+
await this.selectEngine(msg, engineMatch[1]);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// /model [name] — show or set the per-workspace model override
|
|
713
|
+
const modelMatch = msg.text.match(/^\/model(?:\s+(\S+))?$/);
|
|
714
|
+
if (modelMatch) {
|
|
715
|
+
if (!ws) {
|
|
380
716
|
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const requested = modelMatch[1];
|
|
720
|
+
if (!requested) {
|
|
721
|
+
await this.channel.sendMessage(msg.chatId, ws.model ? `Saved model: ${ws.model}` : "No model override set. New sessions use the engine's default.");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (requested === "default") {
|
|
725
|
+
this.config.setModel(ws.name, undefined);
|
|
726
|
+
await this.channel.sendMessage(msg.chatId, "Model override cleared. Your current session is kept; use /new to start with the engine's default.");
|
|
727
|
+
log.info("[cmd] workspace %s model override cleared", ws.name);
|
|
728
|
+
return;
|
|
381
729
|
}
|
|
730
|
+
this.config.setModel(ws.name, requested);
|
|
731
|
+
await this.channel.sendMessage(msg.chatId, `Model set to ${requested} for the next turn.`);
|
|
732
|
+
log.info("[cmd] workspace %s model → %s", ws.name, requested);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!ws) {
|
|
736
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat. Create a workspace with a manual chat from home or another workspace, then send /connect <workspace> here.");
|
|
382
737
|
return;
|
|
383
738
|
}
|
|
384
739
|
this.enqueueMessage(msg, ws, state);
|
|
@@ -402,39 +757,15 @@ export class Orchestrator {
|
|
|
402
757
|
case "text_chunk": {
|
|
403
758
|
if (turnState.staySilent)
|
|
404
759
|
break;
|
|
405
|
-
|
|
406
|
-
const plainOpts = { format: "plain" };
|
|
407
|
-
if (state.textHandle) {
|
|
408
|
-
try {
|
|
409
|
-
await this.channel.editMessage(chatId, state.textHandle, state.textBuffer, plainOpts);
|
|
410
|
-
}
|
|
411
|
-
catch {
|
|
412
|
-
const handles = await this.channel.sendMessage(chatId, state.textBuffer, {
|
|
413
|
-
...plainOpts,
|
|
414
|
-
replyToMessageId: turnState.replyToMessageId ?? undefined,
|
|
415
|
-
});
|
|
416
|
-
state.textHandle = handles[0];
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
const handles = await this.channel.sendMessage(chatId, state.textBuffer, {
|
|
421
|
-
...plainOpts,
|
|
422
|
-
replyToMessageId: turnState.replyToMessageId ?? undefined,
|
|
423
|
-
});
|
|
424
|
-
state.textHandle = handles[0];
|
|
425
|
-
}
|
|
760
|
+
await this.routeTextChunk(chatId, event.text, state, turnState);
|
|
426
761
|
break;
|
|
427
762
|
}
|
|
428
763
|
case "tool_use": {
|
|
429
764
|
// Flush streaming text — final edit with markdown formatting
|
|
430
|
-
|
|
431
|
-
try {
|
|
432
|
-
await this.channel.editMessage(chatId, state.textHandle, state.textBuffer);
|
|
433
|
-
}
|
|
434
|
-
catch { /* best effort */ }
|
|
435
|
-
}
|
|
765
|
+
await this.flushTextSegment(chatId, state, turnState);
|
|
436
766
|
state.textHandle = null;
|
|
437
767
|
state.textBuffer = "";
|
|
768
|
+
state.textPublishedLength = 0;
|
|
438
769
|
// In assistant behavior, all tool status is suppressed (no rolling status, no plan mode notifications)
|
|
439
770
|
if (behavior === "assistant")
|
|
440
771
|
break;
|
|
@@ -481,16 +812,10 @@ export class Orchestrator {
|
|
|
481
812
|
}
|
|
482
813
|
case "tool_result":
|
|
483
814
|
break;
|
|
484
|
-
case "
|
|
485
|
-
|
|
486
|
-
? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
|
|
487
|
-
: "";
|
|
488
|
-
log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
|
|
489
|
-
await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
|
|
490
|
-
break;
|
|
491
|
-
}
|
|
815
|
+
case "plan_usage":
|
|
816
|
+
break; // Captured account-wide in executeTurn.
|
|
492
817
|
case "done":
|
|
493
|
-
// Handled inline in executeTurn
|
|
818
|
+
// Handled inline in executeTurn for session persistence.
|
|
494
819
|
break;
|
|
495
820
|
case "error":
|
|
496
821
|
log.error(`[turn] error: ${event.message}`);
|
|
@@ -504,6 +829,66 @@ export class Orchestrator {
|
|
|
504
829
|
log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
|
|
505
830
|
}
|
|
506
831
|
}
|
|
832
|
+
async routeTextChunk(chatId, text, state, turnState) {
|
|
833
|
+
state.textBuffer += text;
|
|
834
|
+
while (state.textBuffer.length > STREAM_EDIT_LIMIT) {
|
|
835
|
+
const splitAt = streamSplitPoint(state.textBuffer, STREAM_EDIT_LIMIT);
|
|
836
|
+
const segment = state.textBuffer.slice(0, splitAt);
|
|
837
|
+
state.textBuffer = state.textBuffer.slice(state.textBuffer.charAt(splitAt) === "\n" ? splitAt + 1 : splitAt);
|
|
838
|
+
await this.publishTextSegment(chatId, segment, state, turnState, true);
|
|
839
|
+
state.textHandle = null;
|
|
840
|
+
state.textPublishedLength = 0;
|
|
841
|
+
}
|
|
842
|
+
if (state.textBuffer.length > 0
|
|
843
|
+
&& (state.textHandle
|
|
844
|
+
? state.textBuffer.length - state.textPublishedLength >= STREAM_EDIT_DELTA
|
|
845
|
+
: state.textBuffer.length >= STREAM_INITIAL_EDIT_MIN)) {
|
|
846
|
+
await this.publishTextSegment(chatId, state.textBuffer, state, turnState, false);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async publishTextSegment(chatId, text, state, turnState, final) {
|
|
850
|
+
const plainOpts = { format: "plain" };
|
|
851
|
+
if (state.textHandle) {
|
|
852
|
+
try {
|
|
853
|
+
await this.channel.editMessage(chatId, state.textHandle, text, final ? undefined : plainOpts);
|
|
854
|
+
state.textPublishedLength = text.length;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
if (isUnchangedMessageError(err)) {
|
|
859
|
+
state.textPublishedLength = text.length;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (isTransientChannelError(err))
|
|
863
|
+
return;
|
|
864
|
+
state.textHandle = null;
|
|
865
|
+
state.textPublishedLength = 0;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const handles = await this.channel.sendMessage(chatId, text, {
|
|
869
|
+
...(final ? {} : plainOpts),
|
|
870
|
+
replyToMessageId: !state.textReplySent ? (turnState.replyToMessageId ?? undefined) : undefined,
|
|
871
|
+
});
|
|
872
|
+
state.textHandle = handles[0] ?? null;
|
|
873
|
+
state.textPublishedLength = text.length;
|
|
874
|
+
state.textReplySent = true;
|
|
875
|
+
}
|
|
876
|
+
async flushTextSegment(chatId, state, turnState) {
|
|
877
|
+
if (!state.textBuffer)
|
|
878
|
+
return;
|
|
879
|
+
if (!state.textHandle) {
|
|
880
|
+
try {
|
|
881
|
+
await this.publishTextSegment(chatId, state.textBuffer, state, turnState, true);
|
|
882
|
+
}
|
|
883
|
+
catch { /* best effort */ }
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
await this.channel.editMessage(chatId, state.textHandle, state.textBuffer);
|
|
888
|
+
state.textPublishedLength = state.textBuffer.length;
|
|
889
|
+
}
|
|
890
|
+
catch { /* best effort */ }
|
|
891
|
+
}
|
|
507
892
|
buildMcpTools(chatId, behavior, turnState) {
|
|
508
893
|
const tools = [
|
|
509
894
|
tool("send_file", "Send a file or image to the current chat conversation", {
|
|
@@ -522,39 +907,234 @@ export class Orchestrator {
|
|
|
522
907
|
return { content: [{ type: "text", text: `Sent ${name} to chat` }] };
|
|
523
908
|
}),
|
|
524
909
|
];
|
|
525
|
-
//
|
|
526
|
-
if (this.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
910
|
+
// Scheduled prompts run in normal workspace sessions.
|
|
911
|
+
if (this.scheduler) {
|
|
912
|
+
const sched = this.scheduler;
|
|
913
|
+
tools.push(tool("schedule_create", "Create a scheduled prompt. Accepts a cron expression for recurring, or an ISO timestamp for one-off (auto-deleted after firing). For timestamps, check current time first to ensure correctness.", {
|
|
914
|
+
cron: z.string().describe("Cron expression (e.g. '0 9 * * *') or ISO timestamp (e.g. '2026-05-03T15:00:00') for one-off. Check current time before setting timestamps."),
|
|
915
|
+
prompt: z.string().describe("The prompt text to run on schedule"),
|
|
916
|
+
timezone: z.string().optional().describe("IANA timezone (e.g. 'America/Los_Angeles'). Defaults to system timezone"),
|
|
917
|
+
}, async (args) => {
|
|
918
|
+
const entry = {
|
|
919
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
920
|
+
cron: args.cron,
|
|
921
|
+
prompt: args.prompt,
|
|
922
|
+
enabled: true,
|
|
923
|
+
timezone: args.timezone,
|
|
924
|
+
createdAt: Date.now(),
|
|
925
|
+
};
|
|
926
|
+
sched.add(entry);
|
|
927
|
+
const isDate = !isNaN(new Date(args.cron).getTime());
|
|
928
|
+
return { content: [{ type: "text", text: `Schedule "${entry.id}" created: ${args.cron}${args.timezone ? ` (${args.timezone})` : ""}${isDate ? " [one-off]" : ""}` }] };
|
|
929
|
+
}), tool("schedule_list", "List all scheduled prompts", {}, async () => {
|
|
930
|
+
const entries = sched.list();
|
|
931
|
+
if (entries.length === 0) {
|
|
932
|
+
return { content: [{ type: "text", text: "No schedules configured." }] };
|
|
933
|
+
}
|
|
934
|
+
const lines = entries.map((e) => `• ${e.id} — ${e.enabled ? "✓" : "✗"} ${e.cron}${e.timezone ? ` (${e.timezone})` : ""}\n ${e.prompt.slice(0, 80)}`);
|
|
935
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
936
|
+
}), tool("schedule_delete", "Delete a scheduled prompt", {
|
|
937
|
+
id: z.string().describe("Schedule ID to delete"),
|
|
938
|
+
}, async (args) => {
|
|
939
|
+
sched.remove(args.id);
|
|
940
|
+
return { content: [{ type: "text", text: `Schedule "${args.id}" deleted.` }] };
|
|
941
|
+
}), tool("schedule_toggle", "Enable or disable a scheduled prompt", {
|
|
942
|
+
id: z.string().describe("Schedule ID to toggle"),
|
|
943
|
+
enabled: z.boolean().describe("Whether to enable or disable the schedule"),
|
|
534
944
|
}, async (args) => {
|
|
535
|
-
|
|
536
|
-
|
|
945
|
+
sched.toggle(args.id, args.enabled);
|
|
946
|
+
return { content: [{ type: "text", text: `Schedule "${args.id}" ${args.enabled ? "enabled" : "disabled"}.` }] };
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
// Workspace tools are available in every workspace conversation.
|
|
950
|
+
if (this.config.workspaceByChat(chatId)) {
|
|
951
|
+
const self = this.config.workspaceByChat(chatId);
|
|
952
|
+
const peers = this.config.listWorkspaces().filter((w) => w.name !== self?.name);
|
|
953
|
+
const projectNames = this.config.listProjects().map((p) => `"${p.name}"`).join(", ") || "(none)";
|
|
954
|
+
tools.push(tool("list_workspaces", "List workspaces with their project, focus, and whether a chat is connected. Covers every project by default; pass project to scope it. Use this to find a name for message_workspace.", {
|
|
955
|
+
project: z.string().optional().describe("Only list workspaces in this project; omit for all projects"),
|
|
956
|
+
}, async (args) => {
|
|
957
|
+
const all = this.config.listWorkspaces();
|
|
958
|
+
const listed = args.project ? all.filter((w) => w.project === args.project) : all;
|
|
959
|
+
if (listed.length === 0) {
|
|
960
|
+
const known = this.config.listProjects().map((p) => p.name).join(", ") || "(none)";
|
|
961
|
+
return { content: [{ type: "text", text: args.project
|
|
962
|
+
? `No workspaces in project "${args.project}". Known projects: ${known}.`
|
|
963
|
+
: "No workspaces." }] };
|
|
537
964
|
}
|
|
538
|
-
|
|
539
|
-
|
|
965
|
+
const lines = listed
|
|
966
|
+
.map((w) => [
|
|
967
|
+
w.name === self?.name ? `${w.name} (you)` : w.name,
|
|
968
|
+
`project: ${w.project ?? "(none)"}`,
|
|
969
|
+
w.chat_id ? "connected" : "awaiting /connect",
|
|
970
|
+
w.description ? `— ${w.description}` : "",
|
|
971
|
+
].filter(Boolean).join(" | "))
|
|
972
|
+
.sort();
|
|
973
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
974
|
+
}), tool("message_workspace", "Send a message to another workspace, in any project. It is delivered as a turn there and rendered in that chat; it can reply by calling message_workspace back. Use list_workspaces to find a name.", {
|
|
975
|
+
workspace: z.string().describe("Target workspace name; see list_workspaces"),
|
|
976
|
+
message: z.string().describe("The message to send"),
|
|
977
|
+
summary: z.string().describe("One-line gist shown in both chats in place of the full message"),
|
|
978
|
+
}, async (args) => {
|
|
979
|
+
const target = this.config.workspaceByName(args.workspace);
|
|
980
|
+
if (!target) {
|
|
981
|
+
return { content: [{ type: "text", text: `No workspace named "${args.workspace}". Call list_workspaces to see the ${peers.length} reachable.` }] };
|
|
540
982
|
}
|
|
541
|
-
|
|
542
|
-
|
|
983
|
+
if (self && target.name === self.name) {
|
|
984
|
+
return { content: [{ type: "text", text: "Cannot message yourself." }] };
|
|
985
|
+
}
|
|
986
|
+
const fromName = self ? self.name : "unknown";
|
|
987
|
+
const ok = this.deliverToWorkspace(target.name, { kind: "peer", workspaceName: fromName }, args.message);
|
|
988
|
+
if (!ok) {
|
|
989
|
+
return { content: [{ type: "text", text: `Failed to deliver to "${target.name}".` }] };
|
|
990
|
+
}
|
|
991
|
+
await this.channel.sendMessage(chatId, `→ sent to ${target.name}: ${args.summary}`);
|
|
992
|
+
await this.channel.sendMessage(target.chat_id, `← from ${fromName}: ${args.summary}`).catch(() => { });
|
|
993
|
+
log.info("[tool] message_workspace: %s → %s", fromName, target.name);
|
|
994
|
+
return { content: [{ type: "text", text: `Delivered to ${target.name}.` }] };
|
|
995
|
+
}), tool("project_create", `Create a Project around an existing workspace so it can host peers. A workspace that is a peer of another Project is adopted out of it; one that is its Project's main cannot be (reassign that main first). Defaults to the current workspace. Existing Projects: ${projectNames}.`, {
|
|
996
|
+
name: z.string().describe("New Project name"),
|
|
997
|
+
description: z.string().describe("What the Project is about"),
|
|
998
|
+
main_workspace: z.string().optional().describe("Existing workspace to make the Project main; must not be another Project's main. Defaults to the current workspace"),
|
|
999
|
+
}, async (args) => {
|
|
1000
|
+
if (this.config.projectByName(args.name)) {
|
|
1001
|
+
return { content: [{ type: "text", text: `Project "${args.name}" already exists.` }] };
|
|
1002
|
+
}
|
|
1003
|
+
const mainName = args.main_workspace ?? self?.name;
|
|
1004
|
+
if (!mainName) {
|
|
1005
|
+
return { content: [{ type: "text", text: "No current workspace to use as the Project main." }] };
|
|
1006
|
+
}
|
|
1007
|
+
const main = this.config.workspaceByName(mainName);
|
|
1008
|
+
if (!main) {
|
|
1009
|
+
return { content: [{ type: "text", text: `No workspace named "${mainName}".` }] };
|
|
1010
|
+
}
|
|
1011
|
+
// A peer can be adopted out of its project; its main cannot — that would orphan the project.
|
|
1012
|
+
const currentProject = this.config.listProjects().find((p) => p.main_workspace === main.name);
|
|
1013
|
+
if (currentProject) {
|
|
1014
|
+
return { content: [{ type: "text", text: `Workspace "${main.name}" already belongs to project "${currentProject.name}" as its main. Reassign that project's main via project_update first, or archive it.` }] };
|
|
1015
|
+
}
|
|
1016
|
+
this.config.upsertWorkspace({ ...main, project: args.name });
|
|
1017
|
+
this.config.addProject({
|
|
543
1018
|
name: args.name,
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
current_session_id: null,
|
|
547
|
-
behavior: args.behavior,
|
|
548
|
-
engine: args.engine,
|
|
1019
|
+
description: args.description,
|
|
1020
|
+
main_workspace: main.name,
|
|
549
1021
|
});
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1022
|
+
const grouping = main.chat_id ? await this.groupChat(args.name, main.chat_id) : undefined;
|
|
1023
|
+
log.info("[tool] project_create: %s (main %s)", args.name, main.name);
|
|
1024
|
+
return { content: [{ type: "text", text: `Project "${args.name}" created with "${main.name}" as its main workspace.${grouping ? `\n\n${grouping}` : ""}` }] };
|
|
1025
|
+
}), tool("workspace_create", `Hand a strand of work to a NEW peer agent with its own chat, directory, and conversation. The peer joins your own project by default. Pass project to put it somewhere else: name an existing project to join it, or any new name to start that project with this peer as its main. Known projects: ${projectNames}. Prepare cwd yourself first — create a git worktree, clone, or plain directory the way this host and repository expect, and keep owning it; ClearClaw only reads the path and never creates or deletes it. The brief is the peer's first message: goal, decisions already made, and scope, leaving unstated implementation choices open. For an existing workspace use message_workspace instead.`, {
|
|
1026
|
+
name: z.string().min(1).describe("Unique short workspace name"),
|
|
1027
|
+
cwd: z.string().min(1).describe("Absolute path to a directory you have already prepared"),
|
|
1028
|
+
brief: z.string().min(1).describe("Goal, agreed decisions, and scope; delivered as the peer's first message"),
|
|
1029
|
+
description: z.string().optional().describe("One-line focus; defaults to the first 160 characters of the brief's first line"),
|
|
1030
|
+
project: z.string().optional().describe("Existing project to join, or a new project name to create with this peer as its main; defaults to your own project"),
|
|
1031
|
+
behavior: z.enum(["assistant", "relay"]).optional(),
|
|
1032
|
+
engine: z.string().optional(),
|
|
1033
|
+
model: z.string().optional(),
|
|
1034
|
+
}, async (args) => {
|
|
1035
|
+
const currentSelf = this.config.workspaceByChat(chatId);
|
|
1036
|
+
if (!currentSelf)
|
|
1037
|
+
return { content: [{ type: "text", text: "The source workspace is no longer connected." }] };
|
|
1038
|
+
// An unknown name creates that project; a legacy caller with none falls back to the workspace name.
|
|
1039
|
+
const requested = args.project ?? currentSelf.project;
|
|
1040
|
+
const project = requested ? this.config.projectByName(requested) : undefined;
|
|
1041
|
+
const newProjectName = project ? undefined : requested ?? args.name;
|
|
1042
|
+
const base = project ? this.config.workspaceByName(project.main_workspace) : currentSelf;
|
|
1043
|
+
if (!base)
|
|
1044
|
+
return { content: [{ type: "text", text: `Project "${project.name}" has no main workspace.` }] };
|
|
1045
|
+
const resolved = this.peerRuntime(base, args);
|
|
1046
|
+
if (resolved.error)
|
|
1047
|
+
return { content: [{ type: "text", text: resolved.error }] };
|
|
1048
|
+
const invalid = this.validateNewWorkspace(args, newProjectName);
|
|
1049
|
+
if (invalid)
|
|
1050
|
+
return { content: [{ type: "text", text: invalid }] };
|
|
1051
|
+
const runtime = resolved.runtime;
|
|
1052
|
+
const automatic = !!(this.channel.createProjectChat && this.channel.closeProjectChat
|
|
1053
|
+
&& (!base.chat_id || this.channel.ownsId(base.chat_id)));
|
|
1054
|
+
const destination = project ? `project "${project.name}"` : `new project "${newProjectName}"`;
|
|
1055
|
+
const response = await this.channel.sendInteractive(chatId, `Create workspace "${args.name}" at ${args.cwd} in ${destination} using ${runtime.engine ?? this.config.defaultEngine}${runtime.model ? ` / ${runtime.model}` : ""}?\n\n${args.brief.slice(0, 300)}${automatic ? "" : "\nAutomatic chat creation is unavailable for this destination."}`, [[...(automatic ? [{ label: "Create chat", value: "spawn" }] : []),
|
|
1056
|
+
{ label: "Manual chat", value: "manual" }, { label: "Cancel", value: "cancel" }]]);
|
|
1057
|
+
if (response.value !== "spawn" && response.value !== "manual")
|
|
1058
|
+
return { content: [{ type: "text", text: "Workspace creation cancelled." }] };
|
|
1059
|
+
return this.createWorkspace(chatId, currentSelf.name, project, newProjectName, base, { ...args, manual: response.value === "manual" }, runtime);
|
|
1060
|
+
}), tool("workspace_archive", "Archive a workspace: try to close its bound chat, then unbind it even if chat closure fails. Reports any closure error. Always leaves the workspace directory on disk; clean that up with your own tooling. Cannot archive 'default'.", {
|
|
1061
|
+
name: z.string().describe("Workspace to archive"),
|
|
554
1062
|
}, async (args) => {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1063
|
+
if (args.name === "default") {
|
|
1064
|
+
return { content: [{ type: "text", text: "Cannot archive the home workspace." }] };
|
|
1065
|
+
}
|
|
1066
|
+
const target = this.config.workspaceByName(args.name);
|
|
1067
|
+
if (!target) {
|
|
1068
|
+
return { content: [{ type: "text", text: `No workspace named "${args.name}".` }] };
|
|
1069
|
+
}
|
|
1070
|
+
const project = target.project ? this.config.projectByName(target.project) : undefined;
|
|
1071
|
+
if (project?.main_workspace === args.name) {
|
|
1072
|
+
const peers = this.config.listWorkspaces().filter((w) => w.project === project.name && w.name !== args.name);
|
|
1073
|
+
if (peers.length > 0) {
|
|
1074
|
+
return { content: [{ type: "text", text: `Cannot archive "${args.name}": it's the main of project "${project.name}", which still has peers (${peers.map((p) => `"${p.name}"`).join(", ")}). Archive those first, or reassign the main via project_update.` }] };
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const resp = await this.channel.sendInteractive(chatId, `Archive workspace "${args.name}" (${target.cwd})? Its registration will be removed even if closing its chat fails. Its directory is left in place.`, [[{ label: "Archive", value: "yes" }, { label: "Cancel", value: "no" }]]);
|
|
1078
|
+
if (resp.value !== "yes") {
|
|
1079
|
+
return { content: [{ type: "text", text: "Archive cancelled by the user." }] };
|
|
1080
|
+
}
|
|
1081
|
+
if (target.chat_id && (!this.channel.ownsId(target.chat_id) || !this.channel.closeProjectChat)) {
|
|
1082
|
+
return { content: [{ type: "text", text: "Cannot archive: this channel cannot close the workspace's chat." }] };
|
|
1083
|
+
}
|
|
1084
|
+
// Whole Telegram chats and deleted topics can be impossible to close.
|
|
1085
|
+
let archiveNote = "";
|
|
1086
|
+
try {
|
|
1087
|
+
if (target.chat_id)
|
|
1088
|
+
await this.channel.closeProjectChat(target.chat_id, target.project);
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1092
|
+
log.warn("[tool] workspace_archive: chat closure failed, unbinding anyway: %s", reason);
|
|
1093
|
+
archiveNote = ` Chat closure failed (${reason}).`;
|
|
1094
|
+
}
|
|
1095
|
+
this.config.removeWorkspace(args.name);
|
|
1096
|
+
const removedProject = project?.main_workspace === args.name ? project : undefined;
|
|
1097
|
+
if (removedProject)
|
|
1098
|
+
this.config.removeProject(removedProject.name);
|
|
1099
|
+
archiveNote += " Its directory is left in place; clean up with your own tooling.";
|
|
1100
|
+
log.info("[tool] workspace_archive: %s", args.name);
|
|
1101
|
+
return { content: [{ type: "text", text: `Workspace "${args.name}" archived.${archiveNote}` }] };
|
|
1102
|
+
}), tool("project_update", "Update a project's shared context: its description, or reassign its main workspace.", {
|
|
1103
|
+
name: z.string().describe("Project name to update"),
|
|
1104
|
+
description: z.string().optional().describe("New description (what the project is about)"),
|
|
1105
|
+
main_workspace: z.string().optional().describe("Reassign the project's main workspace"),
|
|
1106
|
+
}, async (args) => {
|
|
1107
|
+
const proj = this.config.projectByName(args.name);
|
|
1108
|
+
if (!proj) {
|
|
1109
|
+
return { content: [{ type: "text", text: `No project named "${args.name}".` }] };
|
|
1110
|
+
}
|
|
1111
|
+
if (args.main_workspace && !this.config.workspaceByName(args.main_workspace)) {
|
|
1112
|
+
return { content: [{ type: "text", text: `No workspace named "${args.main_workspace}".` }] };
|
|
1113
|
+
}
|
|
1114
|
+
if (args.main_workspace && this.config.workspaceByName(args.main_workspace)?.project !== proj.name) {
|
|
1115
|
+
return { content: [{ type: "text", text: `Choose a main workspace that already belongs to project "${proj.name}".` }] };
|
|
1116
|
+
}
|
|
1117
|
+
this.config.addProject({
|
|
1118
|
+
name: proj.name,
|
|
1119
|
+
description: args.description ?? proj.description,
|
|
1120
|
+
main_workspace: args.main_workspace ?? proj.main_workspace,
|
|
1121
|
+
});
|
|
1122
|
+
log.info("[tool] project_update: %s", args.name);
|
|
1123
|
+
return { content: [{ type: "text", text: `Project "${args.name}" updated.` }] };
|
|
1124
|
+
}), tool("workspace_update", "Update what a workspace is working on (its description). Use /engine and /behavior in that workspace's chat to change runtime settings.", {
|
|
1125
|
+
name: z.string().describe("Workspace to update"),
|
|
1126
|
+
description: z.string().optional().describe("What this workspace is currently working on"),
|
|
1127
|
+
}, async (args) => {
|
|
1128
|
+
const ws = this.config.workspaceByName(args.name);
|
|
1129
|
+
if (!ws) {
|
|
1130
|
+
return { content: [{ type: "text", text: `No workspace named "${args.name}".` }] };
|
|
1131
|
+
}
|
|
1132
|
+
this.config.upsertWorkspace({
|
|
1133
|
+
...ws,
|
|
1134
|
+
description: args.description ?? ws.description,
|
|
1135
|
+
});
|
|
1136
|
+
log.info("[tool] workspace_update: %s", args.name);
|
|
1137
|
+
return { content: [{ type: "text", text: `Workspace "${args.name}" updated.` }] };
|
|
558
1138
|
}));
|
|
559
1139
|
}
|
|
560
1140
|
if (behavior === "assistant") {
|
|
@@ -617,31 +1197,45 @@ export class Orchestrator {
|
|
|
617
1197
|
}
|
|
618
1198
|
}
|
|
619
1199
|
async updateStatusMessage(chatId, state) {
|
|
620
|
-
const
|
|
1200
|
+
const ws = this.config.workspaceByChat(chatId);
|
|
1201
|
+
const baseMode = this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
|
|
1202
|
+
const mode = state.permissionMode ?? baseMode;
|
|
621
1203
|
const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
|
|
622
1204
|
let text;
|
|
623
1205
|
if (state.stats) {
|
|
624
|
-
const
|
|
625
|
-
? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
|
|
626
|
-
:
|
|
627
|
-
const displayName = state.stats.model
|
|
628
|
-
|
|
629
|
-
: state.engineName ?? "agent";
|
|
630
|
-
text = `🤖 ${displayName} ${pct}% | 🔒 ${modeLabel}`;
|
|
1206
|
+
const usage = state.stats.contextWindow > 0
|
|
1207
|
+
? `${Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)}%`
|
|
1208
|
+
: "usage n/a";
|
|
1209
|
+
const displayName = state.stats.modelLabel ?? state.stats.model;
|
|
1210
|
+
text = `🤖 ${displayName ? `${displayName} ` : ""}${usage}`;
|
|
631
1211
|
}
|
|
632
1212
|
else {
|
|
633
|
-
text =
|
|
1213
|
+
text = "";
|
|
634
1214
|
}
|
|
1215
|
+
if (mode !== baseMode)
|
|
1216
|
+
text += `${text ? " | " : ""}🔒 ${modeLabel}`;
|
|
1217
|
+
const engine = ws?.engine ?? state.engineName ?? this.config.defaultEngine;
|
|
1218
|
+
text += `${text ? " | " : ""}${engine}`;
|
|
1219
|
+
const maxLength = this.channel.statusMaxLength ?? 4096;
|
|
1220
|
+
if (text.length > maxLength - 80)
|
|
1221
|
+
text = `${text.slice(0, maxLength - 81)}…`;
|
|
1222
|
+
const usage = this.planUsage.get(engine) ?? {
|
|
1223
|
+
windows: new Map(this.engines.get(engine)?.planUsageWindows?.map((window) => [window.id, window])),
|
|
1224
|
+
};
|
|
1225
|
+
const quota = formatPlanUsage(usage, maxLength - text.length - 3);
|
|
1226
|
+
if (quota)
|
|
1227
|
+
text += `${text ? " | " : ""}${quota}`;
|
|
635
1228
|
if (text === state.lastStatusText)
|
|
636
1229
|
return;
|
|
637
|
-
|
|
638
|
-
|
|
1230
|
+
try {
|
|
1231
|
+
await this.channel.updateStatus(chatId, text);
|
|
1232
|
+
state.lastStatusText = text;
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
log.warn({ err }, "[status] failed to update status for chat %s", chatId);
|
|
1236
|
+
}
|
|
639
1237
|
}
|
|
640
1238
|
}
|
|
641
|
-
/** Strip "claude-" prefix and date suffix from model ID. e.g. "claude-opus-4-6-20250514" → "opus-4-6" */
|
|
642
|
-
function formatModelName(modelId) {
|
|
643
|
-
return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
|
|
644
|
-
}
|
|
645
1239
|
/** Format reply context as a bracketed prefix line for the LLM prompt. */
|
|
646
1240
|
function formatReplyLine(replyTo) {
|
|
647
1241
|
if (!replyTo)
|
|
@@ -660,15 +1254,76 @@ function formatReplyLine(replyTo) {
|
|
|
660
1254
|
* Build the turn prompt: `[msg:N] sender: text`, one line per message.
|
|
661
1255
|
* Works for both single-message (relay) and batched (assistant) turns.
|
|
662
1256
|
*/
|
|
663
|
-
function
|
|
1257
|
+
function formatTimestamp() {
|
|
1258
|
+
const now = new Date();
|
|
1259
|
+
const day = now.toLocaleDateString("en-US", { weekday: "short" });
|
|
1260
|
+
const date = now.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
|
1261
|
+
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short" });
|
|
1262
|
+
return `${date} ${day} ${time}`;
|
|
1263
|
+
}
|
|
1264
|
+
function senderLabel(origin) {
|
|
1265
|
+
switch (origin.kind) {
|
|
1266
|
+
// Keep the ID inside the brackets, because stripPromptPrefix stops at the first colon outside them.
|
|
1267
|
+
case "user": return `[user ${origin.user.id}] ${origin.user.handle ? `${origin.user.name} (@${origin.user.handle})` : origin.user.name}`;
|
|
1268
|
+
case "scheduler": return `[scheduler] ${origin.scheduleId}`;
|
|
1269
|
+
case "peer": return `[peer] ${origin.workspaceName}`;
|
|
1270
|
+
default: {
|
|
1271
|
+
const _exhaustive = origin;
|
|
1272
|
+
return _exhaustive;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* A lone slash-command message (built-in like `/compact`, `/context`, or a
|
|
1278
|
+
* custom `.claude/commands/<name>`) must reach the engine verbatim: the CLI
|
|
1279
|
+
* only intercepts a slash command when it sits at position 0 of the prompt.
|
|
1280
|
+
* buildPrompt's `[ts] sender:` framing would push it off position 0, so the
|
|
1281
|
+
* command would be sent to the model as plain text instead of executed.
|
|
1282
|
+
*
|
|
1283
|
+
* ClearClaw's own commands (/new, /resume, /behavior, …) are handled and
|
|
1284
|
+
* returned before the turn starts, so any leading-slash message that reaches
|
|
1285
|
+
* here is a pass-through command. Restricted to single, user-typed messages —
|
|
1286
|
+
* a slash buried in a batch, or in a scheduler/peer prompt, is not a command.
|
|
1287
|
+
* Returns the bare command text, or null to fall back to buildPrompt.
|
|
1288
|
+
*/
|
|
1289
|
+
function slashCommandPrompt(messages) {
|
|
1290
|
+
if (messages.length !== 1)
|
|
1291
|
+
return null;
|
|
1292
|
+
const msg = messages[0];
|
|
1293
|
+
if (msg.origin.kind !== "user")
|
|
1294
|
+
return null;
|
|
1295
|
+
const text = msg.text.trim();
|
|
1296
|
+
return text.startsWith("/") ? text : null;
|
|
1297
|
+
}
|
|
1298
|
+
function streamSplitPoint(text, limit) {
|
|
1299
|
+
if (text.length <= limit)
|
|
1300
|
+
return text.length;
|
|
1301
|
+
const newlineAt = text.lastIndexOf("\n", limit);
|
|
1302
|
+
return newlineAt > 0 ? newlineAt : limit;
|
|
1303
|
+
}
|
|
1304
|
+
function isTransientChannelError(err) {
|
|
1305
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1306
|
+
return message.includes("Too Many Requests") || message.includes("429:");
|
|
1307
|
+
}
|
|
1308
|
+
function isUnchangedMessageError(err) {
|
|
1309
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1310
|
+
return message.includes("message is not modified");
|
|
1311
|
+
}
|
|
1312
|
+
export function buildPrompt(messages) {
|
|
1313
|
+
const ts = formatTimestamp();
|
|
664
1314
|
return messages.map((msg) => {
|
|
665
|
-
const sender =
|
|
1315
|
+
const sender = senderLabel(msg.origin);
|
|
666
1316
|
const msgIdPrefix = msg.messageId ? `[msg:${msg.messageId}] ` : "";
|
|
667
1317
|
const replyLine = formatReplyLine(msg.replyTo);
|
|
668
|
-
|
|
669
|
-
? ` [${msg.attachments.length} attachment(s)]`
|
|
670
|
-
: "";
|
|
671
|
-
return `${replyLine}${msgIdPrefix}${sender}: ${msg.text}${attachmentNote}`;
|
|
1318
|
+
return `${replyLine}[${ts}] ${msgIdPrefix}${sender}: ${msg.text}`;
|
|
672
1319
|
}).join("\n");
|
|
673
1320
|
}
|
|
1321
|
+
/** Preserve the original source across multiple switches before a handoff succeeds. */
|
|
1322
|
+
function captureHandoff(ctx, engine) {
|
|
1323
|
+
const sessionId = ctx.current_session_id;
|
|
1324
|
+
return ctx.engine_handoff ?? (sessionId ? { engine, sessionId, cwd: ctx.cwd } : undefined);
|
|
1325
|
+
}
|
|
1326
|
+
function sameHandoff(left, right) {
|
|
1327
|
+
return left?.engine === right.engine && left.sessionId === right.sessionId && left.cwd === right.cwd;
|
|
1328
|
+
}
|
|
674
1329
|
//# sourceMappingURL=orchestrator.js.map
|