clearclaw 0.7.0 → 0.9.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 +20 -1
- package/dist/channel/telegram.js +146 -33
- 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 +38 -4
- package/dist/orchestrator.js +854 -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 +75 -21
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +82 -0
- package/dist/transcribe.d.ts +13 -0
- package/dist/transcribe.js +157 -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,31 +1,40 @@
|
|
|
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";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import log from "./logger.js";
|
|
6
7
|
import { saveFile } from "./files.js";
|
|
8
|
+
import { isAudio, transcribeAudio } from "./transcribe.js";
|
|
7
9
|
import { assemblePrompt } from "./prompt.js";
|
|
10
|
+
import { formatPlanUsage, recordPlanUsage } from "./plan-usage.js";
|
|
8
11
|
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, formatTodoList, timeAgo } from "./format.js";
|
|
9
12
|
import { permissionHandlers, displayHandledTools } from "./tool-handlers.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
+
import { formatSessionRecap, formatSessionTranscript, stripPromptPrefix } from "./engine/session-transcript.js";
|
|
14
|
+
import { Scheduler } from "./scheduler.js";
|
|
13
15
|
const MODE_OPTIONS = [
|
|
14
16
|
{ label: "Default", value: "default" },
|
|
15
17
|
{ label: "Accept Edits", value: "acceptEdits" },
|
|
16
18
|
{ label: "Plan", value: "plan" },
|
|
17
19
|
{ label: "Bypass", value: "bypassPermissions" },
|
|
18
20
|
];
|
|
21
|
+
const STREAM_EDIT_LIMIT = 3000;
|
|
22
|
+
const STREAM_INITIAL_EDIT_MIN = 96;
|
|
23
|
+
const STREAM_EDIT_DELTA = 160;
|
|
19
24
|
export class Orchestrator {
|
|
20
25
|
channel;
|
|
21
26
|
engines;
|
|
22
27
|
config;
|
|
23
28
|
chats = new Map();
|
|
24
|
-
|
|
29
|
+
creatingWorkspaces = new Set();
|
|
30
|
+
scheduler = null;
|
|
31
|
+
planUsage = new Map();
|
|
32
|
+
transcribe;
|
|
25
33
|
constructor(opts) {
|
|
26
34
|
this.channel = opts.channel;
|
|
27
35
|
this.engines = opts.engines;
|
|
28
36
|
this.config = opts.config;
|
|
37
|
+
this.transcribe = opts.transcribe ?? transcribeAudio;
|
|
29
38
|
}
|
|
30
39
|
chat(chatId) {
|
|
31
40
|
let s = this.chats.get(chatId);
|
|
@@ -33,6 +42,8 @@ export class Orchestrator {
|
|
|
33
42
|
s = {
|
|
34
43
|
busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
|
|
35
44
|
toolCallHandle: null, todoHandle: null, textHandle: null, textBuffer: "",
|
|
45
|
+
textPublishedLength: 0,
|
|
46
|
+
textReplySent: false,
|
|
36
47
|
engineName: null,
|
|
37
48
|
messageQueue: [], debounceTimer: null,
|
|
38
49
|
};
|
|
@@ -49,13 +60,18 @@ export class Orchestrator {
|
|
|
49
60
|
return engine;
|
|
50
61
|
}
|
|
51
62
|
async start() {
|
|
63
|
+
this.config.ensureHomeWorkspace();
|
|
52
64
|
this.channel.on("message", (msg) => {
|
|
53
65
|
this.routeMessage(msg).catch((err) => {
|
|
54
66
|
log.error({ err }, "[orchestrator] unhandled message error");
|
|
55
67
|
});
|
|
56
68
|
});
|
|
57
69
|
await this.channel.connect();
|
|
70
|
+
for (const ws of this.config.listWorkspaces())
|
|
71
|
+
this.deliverInitialBrief(ws.name);
|
|
58
72
|
log.info("ClearClaw ready.");
|
|
73
|
+
this.scheduler = new Scheduler(this.config, (msg) => this.deliverToWorkspace("default", msg.origin, msg.text));
|
|
74
|
+
this.scheduler.start();
|
|
59
75
|
const shutdown = async () => {
|
|
60
76
|
await this.stop();
|
|
61
77
|
process.exit(0);
|
|
@@ -64,18 +80,192 @@ export class Orchestrator {
|
|
|
64
80
|
process.on("SIGTERM", shutdown);
|
|
65
81
|
}
|
|
66
82
|
async stop() {
|
|
83
|
+
this.scheduler?.stop();
|
|
67
84
|
await this.channel.disconnect();
|
|
68
85
|
}
|
|
69
|
-
/**
|
|
86
|
+
/** Deliver a synthetic message to a named workspace and trigger its turn. */
|
|
87
|
+
deliverToWorkspace(workspaceName, origin, text) {
|
|
88
|
+
const ws = this.config.workspaceByName(workspaceName);
|
|
89
|
+
if (!ws?.chat_id || !this.channel.ownsId(ws.chat_id)) {
|
|
90
|
+
log.warn("[deliver] workspace '%s' not found", workspaceName);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const msg = {
|
|
94
|
+
chatId: ws.chat_id,
|
|
95
|
+
chatType: ws.name === "default" ? "dm" : "group",
|
|
96
|
+
text,
|
|
97
|
+
origin,
|
|
98
|
+
};
|
|
99
|
+
this.enqueueMessage(msg, ws, this.chat(ws.chat_id));
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Everything creation can reject without touching the platform. Runs before the
|
|
104
|
+
* confirmation, so the user is never asked to approve a doomed spawn, and again
|
|
105
|
+
* inside creation, where it is the authoritative check.
|
|
106
|
+
*/
|
|
107
|
+
validateNewWorkspace(args, newProjectName) {
|
|
108
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(args.name)) {
|
|
109
|
+
return "Use a short workspace name containing letters, numbers, hyphens, or underscores.";
|
|
110
|
+
}
|
|
111
|
+
if (this.config.workspaceByName(args.name) || this.creatingWorkspaces.has(args.name)) {
|
|
112
|
+
return `Workspace "${args.name}" already exists or is being created. Pick another name.`;
|
|
113
|
+
}
|
|
114
|
+
if (newProjectName && this.config.projectByName(newProjectName)) {
|
|
115
|
+
return `Project "${newProjectName}" already exists. Name it as project to put this peer in it.`;
|
|
116
|
+
}
|
|
117
|
+
if (!path.isAbsolute(args.cwd) || !fs.existsSync(args.cwd) || !fs.statSync(args.cwd).isDirectory()) {
|
|
118
|
+
return `cwd "${args.cwd}" must be an existing directory with an absolute path. Prepare it with your own tooling, then retry.`;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Shared creation path for standalone projects and peers, with optional manual binding. */
|
|
123
|
+
async createWorkspace(chatId, fromName, project, newProjectName, mainWs, args, runtime) {
|
|
124
|
+
const result = (text) => ({ content: [{ type: "text", text }] });
|
|
125
|
+
const invalid = this.validateNewWorkspace(args, newProjectName);
|
|
126
|
+
if (invalid)
|
|
127
|
+
return result(invalid);
|
|
128
|
+
const description = args.description ?? args.brief.split("\n")[0].slice(0, 160);
|
|
129
|
+
const destination = project ?? { name: newProjectName, main_workspace: args.name, description };
|
|
130
|
+
const anchor = mainWs.chat_id ?? chatId;
|
|
131
|
+
if (!args.manual && (!this.channel.ownsId(anchor) || !this.channel.createProjectChat || !this.channel.closeProjectChat)) {
|
|
132
|
+
return result("Automatic chat creation is unavailable for this destination. Retry with a manual chat, then use /connect <workspace> in that chat.");
|
|
133
|
+
}
|
|
134
|
+
this.creatingWorkspaces.add(args.name);
|
|
135
|
+
const cwd = args.cwd;
|
|
136
|
+
let createdChatId = null;
|
|
137
|
+
let registered = false;
|
|
138
|
+
let createdProject = false;
|
|
139
|
+
try {
|
|
140
|
+
if (!args.manual)
|
|
141
|
+
createdChatId = await this.channel.createProjectChat(destination.name, anchor, args.name);
|
|
142
|
+
// Chat creation awaits the platform. Recheck config before committing our records.
|
|
143
|
+
if (this.config.workspaceByName(args.name) || (newProjectName && this.config.projectByName(newProjectName))) {
|
|
144
|
+
throw new Error("The workspace or project name was taken during chat creation. Retry with another name.");
|
|
145
|
+
}
|
|
146
|
+
if (createdChatId && this.config.workspaceByChat(createdChatId))
|
|
147
|
+
throw new Error("The created chat is already connected to another workspace.");
|
|
148
|
+
if (project && !this.config.projectByName(project.name))
|
|
149
|
+
throw new Error("The target project was archived during chat creation.");
|
|
150
|
+
this.config.upsertWorkspace({
|
|
151
|
+
name: args.name, cwd, chat_id: createdChatId, current_session_id: null,
|
|
152
|
+
behavior: args.behavior ?? mainWs.behavior, engine: runtime.engine, model: runtime.model,
|
|
153
|
+
project: destination.name, description, spawnedFrom: fromName,
|
|
154
|
+
pending_brief: { fromWorkspace: fromName, text: args.brief },
|
|
155
|
+
});
|
|
156
|
+
registered = true;
|
|
157
|
+
if (!project) {
|
|
158
|
+
this.config.addProject(destination);
|
|
159
|
+
createdProject = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (createdProject)
|
|
164
|
+
this.config.removeProject(destination.name);
|
|
165
|
+
if (registered)
|
|
166
|
+
this.config.removeWorkspace(args.name);
|
|
167
|
+
if (createdChatId && !this.config.workspaceByChat(createdChatId)) {
|
|
168
|
+
await this.channel.closeProjectChat(createdChatId, destination.name).catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
return result(`Spawn failed: ${err instanceof Error ? err.message : String(err)}. Retry with a manual chat if automatic chat creation is unavailable.`);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
this.creatingWorkspaces.delete(args.name);
|
|
174
|
+
}
|
|
175
|
+
// Once registered, notification failures must not roll back a workspace or a delivered brief.
|
|
176
|
+
const grouping = createdChatId ? await this.groupChat(destination.name, createdChatId) : undefined;
|
|
177
|
+
const delivered = this.deliverInitialBrief(args.name);
|
|
178
|
+
const note = createdChatId
|
|
179
|
+
? (delivered ? "; brief delivered." : ".")
|
|
180
|
+
: `. In the intended chat, send /connect ${args.name}.`;
|
|
181
|
+
log.info("[workspace] created %s in %s", args.name, destination.name);
|
|
182
|
+
return result(`Spawned workspace "${args.name}" at ${cwd}${note}${grouping ? `\n\n${grouping}` : ""}`);
|
|
183
|
+
}
|
|
184
|
+
/** Grouping is best-effort: it never fails a lifecycle operation, but it is never silent either. */
|
|
185
|
+
async groupChat(projectName, chatId) {
|
|
186
|
+
try {
|
|
187
|
+
return await this.channel.groupProjectChat?.(projectName, chatId);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
log.warn({ err }, "[project] grouping failed for %s", projectName);
|
|
191
|
+
return `Could not group this chat under "${projectName}": ${err instanceof Error ? err.message : String(err)}.`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
deliverInitialBrief(name) {
|
|
195
|
+
const ws = this.config.workspaceByName(name);
|
|
196
|
+
if (!ws?.chat_id || !ws.pending_brief)
|
|
197
|
+
return false;
|
|
198
|
+
// An existing or queued ordinary turn already includes the saved brief.
|
|
199
|
+
const state = this.chats.get(ws.chat_id);
|
|
200
|
+
if (state?.busy || state?.messageQueue.length)
|
|
201
|
+
return false;
|
|
202
|
+
const brief = ws.pending_brief;
|
|
203
|
+
return this.deliverToWorkspace(name, { kind: "peer", workspaceName: brief.fromWorkspace }, brief.text);
|
|
204
|
+
}
|
|
205
|
+
/** An unbound chat can connect without starting a temporary agent or sharing home's session. */
|
|
206
|
+
async connectWorkspace(msg, name) {
|
|
207
|
+
const reply = (text) => this.channel.sendMessage(msg.chatId, text);
|
|
208
|
+
if (!name) {
|
|
209
|
+
await reply("Usage: /connect <workspace>. Create a workspace with a manual chat from home first.");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const existing = this.config.workspaceByChat(msg.chatId);
|
|
213
|
+
if (existing) {
|
|
214
|
+
await reply(`This chat is already connected to "${existing.name}".`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const ws = this.config.workspaceByName(name);
|
|
218
|
+
if (!ws) {
|
|
219
|
+
await reply(`No workspace named "${name}". Create it from home with a manual chat first.`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (name === "default") {
|
|
223
|
+
await reply("Home connects automatically through an authorized private DM.");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (ws.chat_id) {
|
|
227
|
+
await reply(`Workspace "${name}" already has a chat. Existing bindings cannot be replaced by /connect.`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (this.chat(msg.chatId).busy) {
|
|
231
|
+
await reply("A turn is running. Wait for it to finish before connecting.");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.config.upsertWorkspace({ ...ws, chat_id: msg.chatId });
|
|
235
|
+
const grouping = ws.project ? await this.groupChat(ws.project, msg.chatId) : undefined;
|
|
236
|
+
this.deliverInitialBrief(name);
|
|
237
|
+
await reply(`Connected this chat to workspace "${name}".${grouping ? `\n\n${grouping}` : ""}`);
|
|
238
|
+
}
|
|
239
|
+
peerRuntime(baseWorkspace, requested) {
|
|
240
|
+
const baseEngine = baseWorkspace?.engine ?? this.config.defaultEngine;
|
|
241
|
+
const engine = requested.engine ?? baseWorkspace?.engine;
|
|
242
|
+
const effectiveEngine = engine ?? this.config.defaultEngine;
|
|
243
|
+
if (!this.engines.has(effectiveEngine)) {
|
|
244
|
+
return { error: `Unknown engine "${effectiveEngine}". Available: ${[...this.engines.keys()].join(", ")}` };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
runtime: {
|
|
248
|
+
engine,
|
|
249
|
+
model: requested.model
|
|
250
|
+
?? (effectiveEngine === baseEngine
|
|
251
|
+
? baseWorkspace?.model
|
|
252
|
+
: undefined),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Effective behavior: Explicit setting or home→assistant / project→relay. */
|
|
70
257
|
effectiveBehavior(ctx) {
|
|
71
|
-
if (isTask(ctx))
|
|
72
|
-
return "assistant";
|
|
73
258
|
if (ctx.behavior !== undefined)
|
|
74
259
|
return ctx.behavior;
|
|
75
260
|
return ctx.cwd === this.config.homeWorkspacePath ? "assistant" : "relay";
|
|
76
261
|
}
|
|
77
|
-
/**
|
|
262
|
+
/** Permission mode a chat runs in until /mode picks one. */
|
|
263
|
+
baseMode(behavior) {
|
|
264
|
+
return behavior === "assistant" ? "bypassPermissions" : this.config.permissionMode;
|
|
265
|
+
}
|
|
266
|
+
/** Enqueue a message and drain — immediately for relay, debounced for assistant. */
|
|
78
267
|
enqueueMessage(msg, ctx, state) {
|
|
268
|
+
log.info("[msg] %s: %s", senderLabel(msg.origin), msg.text.slice(0, 80));
|
|
79
269
|
state.messageQueue.push(msg);
|
|
80
270
|
if (state.busy)
|
|
81
271
|
return;
|
|
@@ -103,7 +293,7 @@ export class Orchestrator {
|
|
|
103
293
|
const state = this.chat(chatId);
|
|
104
294
|
if (state.messageQueue.length === 0 || state.busy)
|
|
105
295
|
return;
|
|
106
|
-
const ctx = this.
|
|
296
|
+
const ctx = this.config.workspaceByChat(chatId);
|
|
107
297
|
if (!ctx)
|
|
108
298
|
return;
|
|
109
299
|
const messages = [...state.messageQueue];
|
|
@@ -113,6 +303,11 @@ export class Orchestrator {
|
|
|
113
303
|
}
|
|
114
304
|
catch (err) {
|
|
115
305
|
log.error({ err }, "[fatal]");
|
|
306
|
+
// executeTurn marks the chat busy before its own finally is in scope; without this,
|
|
307
|
+
// an early throw leaves the chat wedged until restart.
|
|
308
|
+
state.busy = false;
|
|
309
|
+
state.abort = null;
|
|
310
|
+
await this.channel.setTyping(chatId, false).catch(() => { });
|
|
116
311
|
await this.channel.sendMessage(chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
117
312
|
return;
|
|
118
313
|
}
|
|
@@ -129,30 +324,76 @@ export class Orchestrator {
|
|
|
129
324
|
}
|
|
130
325
|
}
|
|
131
326
|
async executeTurn(chatId, messages, ctx, state) {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
327
|
+
const ws = ctx;
|
|
328
|
+
const initialBrief = slashCommandPrompt(messages) ? undefined : ws.pending_brief;
|
|
329
|
+
if (initialBrief && !messages.some((m) => m.origin.kind === "peer"
|
|
330
|
+
&& m.origin.workspaceName === initialBrief.fromWorkspace && m.text === initialBrief.text)) {
|
|
331
|
+
messages = [{ chatId, chatType: "group", origin: { kind: "peer", workspaceName: initialBrief.fromWorkspace }, text: initialBrief.text }, ...messages];
|
|
332
|
+
}
|
|
333
|
+
const turnCwd = ws.cwd;
|
|
334
|
+
if (!fs.existsSync(turnCwd)) {
|
|
335
|
+
log.warn("[turn] aborting: cwd does not exist: %s", turnCwd);
|
|
336
|
+
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(() => { });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
134
339
|
state.busy = true;
|
|
135
340
|
const abort = new AbortController();
|
|
136
341
|
state.abort = abort;
|
|
137
|
-
const behavior =
|
|
342
|
+
const behavior = messages.some((m) => m.origin.kind === "scheduler")
|
|
343
|
+
? "assistant"
|
|
344
|
+
: this.effectiveBehavior(ctx);
|
|
138
345
|
const turnState = { staySilent: false, replyToMessageId: null };
|
|
139
|
-
const sessionId =
|
|
140
|
-
const cwd =
|
|
141
|
-
const logPrefix =
|
|
346
|
+
const sessionId = ws.current_session_id;
|
|
347
|
+
const cwd = ws.cwd;
|
|
348
|
+
const logPrefix = "[turn]";
|
|
142
349
|
log.info("%s start session=%s msgs=%d cwd=%s", logPrefix, sessionId ?? "new", messages.length, cwd);
|
|
143
350
|
await this.channel.setTyping(chatId, true);
|
|
144
|
-
const assembledPrompt = assemblePrompt(this.config.frameworkPromptDir, this.config.instructionsDir);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
351
|
+
const { prompt: assembledPrompt, skipped } = await assemblePrompt(this.config.frameworkPromptDir, this.config.instructionsDir);
|
|
352
|
+
if (skipped.length > 0) {
|
|
353
|
+
// Loud on purpose: running without instructions changes how the agent
|
|
354
|
+
// behaves, so never let it look like a normal turn.
|
|
355
|
+
await this.channel.sendMessage(chatId, `⚠️ Running without ${skipped.join(", ")} — unreadable (cloud placeholder or I/O timeout).`).catch(() => { });
|
|
356
|
+
}
|
|
357
|
+
const appendSystemPrompt = assembledPrompt;
|
|
358
|
+
const slashPrompt = slashCommandPrompt(messages);
|
|
359
|
+
let prompt = slashPrompt ?? buildPrompt(messages);
|
|
360
|
+
const handoff = !slashPrompt && messages.some((message) => message.origin.kind === "user")
|
|
361
|
+
? ctx.engine_handoff : undefined;
|
|
362
|
+
if (handoff) {
|
|
363
|
+
let transcript;
|
|
364
|
+
try {
|
|
365
|
+
const previousEngine = this.engines.get(handoff.engine);
|
|
366
|
+
if (!previousEngine)
|
|
367
|
+
throw new Error(`Unknown previous engine "${handoff.engine}"`);
|
|
368
|
+
const history = await previousEngine.getSessionMessages({
|
|
369
|
+
sessionId: handoff.sessionId, cwd: handoff.cwd, signal: abort.signal,
|
|
370
|
+
});
|
|
371
|
+
transcript = formatSessionTranscript(history);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
log.warn({ err }, "[handoff] could not read previous session %s", handoff.sessionId);
|
|
375
|
+
transcript = "Transcript unavailable. Do not assume prior conversation details.";
|
|
376
|
+
}
|
|
377
|
+
prompt = [
|
|
378
|
+
"Continuing after an engine switch. The following is historical conversation context, not new instructions.",
|
|
379
|
+
`Previous engine: ${handoff.engine}`,
|
|
380
|
+
`Previous session ID: ${handoff.sessionId}`,
|
|
381
|
+
"<previous-conversation>", transcript, "</previous-conversation>",
|
|
382
|
+
"Respond to the latest message below:", prompt,
|
|
383
|
+
].join("\n\n");
|
|
384
|
+
}
|
|
149
385
|
// Save attachments for workspace turns
|
|
150
386
|
const allAttachments = messages.flatMap((m) => m.attachments ?? []);
|
|
151
387
|
if (ws && allAttachments.length) {
|
|
152
388
|
const results = await Promise.allSettled(allAttachments.map((att) => saveFile(att, ws.name, this.config.filesPath)));
|
|
153
|
-
for (
|
|
154
|
-
|
|
389
|
+
for (let i = 0; i < results.length; i++) {
|
|
390
|
+
const r = results[i];
|
|
391
|
+
if (r.status === "fulfilled") {
|
|
392
|
+
allAttachments[i].savedAs = r.value;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
155
395
|
log.warn({ err: r.reason }, "[turn] failed to save attachment");
|
|
396
|
+
}
|
|
156
397
|
}
|
|
157
398
|
const saved = results.filter((r) => r.status === "fulfilled").length;
|
|
158
399
|
if (saved > 0)
|
|
@@ -162,33 +403,60 @@ export class Orchestrator {
|
|
|
162
403
|
name: "clearclaw",
|
|
163
404
|
tools: this.buildMcpTools(chatId, behavior, turnState),
|
|
164
405
|
});
|
|
165
|
-
const engine =
|
|
406
|
+
const engine = this.engineFor(ws);
|
|
166
407
|
state.engineName = engine.name;
|
|
408
|
+
let turnFailed = false;
|
|
167
409
|
try {
|
|
410
|
+
abort.signal.throwIfAborted();
|
|
168
411
|
for await (const event of engine.runTurn({
|
|
169
412
|
sessionId,
|
|
170
413
|
cwd,
|
|
171
414
|
prompt,
|
|
172
415
|
attachments: ws && allAttachments.length > 0 ? allAttachments : undefined,
|
|
173
|
-
permissionMode: state.permissionMode ?? (behavior
|
|
416
|
+
permissionMode: state.permissionMode ?? this.baseMode(behavior),
|
|
174
417
|
appendSystemPrompt,
|
|
175
418
|
mcpServers: { clearclaw: mcpServer },
|
|
176
419
|
signal: abort.signal,
|
|
177
420
|
onPermissionRequest: (req) => this.handlePermission(req, chatId),
|
|
421
|
+
model: ws?.model,
|
|
178
422
|
})) {
|
|
179
|
-
|
|
423
|
+
if (event.type === "plan_usage") {
|
|
424
|
+
let usage = this.planUsage.get(engine.name);
|
|
425
|
+
if (!usage) {
|
|
426
|
+
usage = { windows: new Map(engine.planUsageWindows?.map((window) => [window.id, window])) };
|
|
427
|
+
this.planUsage.set(engine.name, usage);
|
|
428
|
+
}
|
|
429
|
+
recordPlanUsage(usage, event);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (event.type === "error")
|
|
433
|
+
turnFailed = true;
|
|
434
|
+
// Persist the session as soon as the engine reports it —
|
|
435
|
+
// so cancelling mid-turn doesn't lose it.
|
|
436
|
+
if (event.type === "session") {
|
|
437
|
+
this.persistSessionId(ws, event.sessionId);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// Persist completion only while the workspace still belongs to this turn.
|
|
180
441
|
if (event.type === "done") {
|
|
181
442
|
log.info("%s done session=%s", logPrefix, event.sessionId);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
443
|
+
// Observed model/usage belongs in stats, never in the model override.
|
|
444
|
+
const persistedSessionId = this.persistSessionId(ws, event.sessionId);
|
|
445
|
+
if (persistedSessionId !== undefined && event.stats)
|
|
446
|
+
state.stats = event.stats;
|
|
447
|
+
if (initialBrief && persistedSessionId !== undefined && !turnFailed && !abort.signal.aborted) {
|
|
448
|
+
const current = this.config.workspaceByName(ws.name);
|
|
449
|
+
if (current?.pending_brief?.text === initialBrief.text
|
|
450
|
+
&& current.pending_brief.fromWorkspace === initialBrief.fromWorkspace) {
|
|
451
|
+
this.config.upsertWorkspace({ ...current, pending_brief: undefined });
|
|
452
|
+
}
|
|
186
453
|
}
|
|
187
|
-
|
|
188
|
-
this.config.
|
|
454
|
+
if (handoff && persistedSessionId !== undefined && !turnFailed && !abort.signal.aborted) {
|
|
455
|
+
const current = this.config.workspaceByName(ws.name);
|
|
456
|
+
if (current && sameHandoff(current.engine_handoff, handoff)) {
|
|
457
|
+
this.config.upsertWorkspace({ ...current, engine_handoff: undefined });
|
|
458
|
+
}
|
|
189
459
|
}
|
|
190
|
-
if (event.stats)
|
|
191
|
-
state.stats = event.stats;
|
|
192
460
|
if (behavior === "relay" && state.toolCallHandle && event.stats) {
|
|
193
461
|
const summary = formatToolCallSummary(event.stats.toolCalls);
|
|
194
462
|
if (summary) {
|
|
@@ -200,15 +468,11 @@ export class Orchestrator {
|
|
|
200
468
|
}
|
|
201
469
|
state.toolCallHandle = null;
|
|
202
470
|
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
|
-
}
|
|
471
|
+
await this.flushTextSegment(chatId, state, turnState);
|
|
210
472
|
state.textHandle = null;
|
|
211
473
|
state.textBuffer = "";
|
|
474
|
+
state.textPublishedLength = 0;
|
|
475
|
+
state.textReplySent = false;
|
|
212
476
|
await this.updateStatusMessage(chatId, state);
|
|
213
477
|
break;
|
|
214
478
|
}
|
|
@@ -222,27 +486,110 @@ export class Orchestrator {
|
|
|
222
486
|
if (!turnState.staySilent) {
|
|
223
487
|
await this.channel.setTyping(chatId, false);
|
|
224
488
|
}
|
|
225
|
-
|
|
226
|
-
if (cancelled && !task) {
|
|
489
|
+
if (cancelled) {
|
|
227
490
|
await this.channel.sendMessage(chatId, "Turn cancelled.");
|
|
228
491
|
}
|
|
229
492
|
}
|
|
230
493
|
}
|
|
494
|
+
persistSessionId(ws, sessionId) {
|
|
495
|
+
const current = this.config.workspaceByName(ws.name);
|
|
496
|
+
// Ignore late events after an archive, rebind, or engine switch.
|
|
497
|
+
if (!current || current.chat_id !== ws.chat_id
|
|
498
|
+
|| (current.engine ?? this.config.defaultEngine) !== (ws.engine ?? this.config.defaultEngine))
|
|
499
|
+
return undefined;
|
|
500
|
+
this.config.setSession(ws.name, sessionId);
|
|
501
|
+
return sessionId;
|
|
502
|
+
}
|
|
503
|
+
/** Native selection works even when the workspace's engine cannot run. */
|
|
504
|
+
async selectEngine(msg, requested) {
|
|
505
|
+
const state = this.chat(msg.chatId);
|
|
506
|
+
const ws = this.config.workspaceByChat(msg.chatId);
|
|
507
|
+
if (!ws) {
|
|
508
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat. Use /connect <workspace> first.");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (state.busy) {
|
|
512
|
+
await this.channel.sendMessage(msg.chatId, "A turn is running. Use /cancel first, then /engine once it stops.");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const current = ws.engine ?? this.config.defaultEngine;
|
|
516
|
+
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;
|
|
517
|
+
if (!selected)
|
|
518
|
+
return;
|
|
519
|
+
if (!this.engines.has(selected)) {
|
|
520
|
+
await this.channel.sendMessage(msg.chatId, `Unknown engine "${selected}". Available: ${[...this.engines.keys()].join(", ")}`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (state.busy) {
|
|
524
|
+
await this.channel.sendMessage(msg.chatId, "A turn is running. Use /cancel first, then /engine once it stops.");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const latest = this.config.workspaceByChat(msg.chatId);
|
|
528
|
+
if (!latest || latest.name !== ws.name || (latest.engine ?? this.config.defaultEngine) !== current) {
|
|
529
|
+
await this.channel.sendMessage(msg.chatId, "Workspace or engine changed while choosing. Run /engine again.");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const changed = selected !== current;
|
|
533
|
+
if (changed) {
|
|
534
|
+
this.config.upsertWorkspace({ ...latest, engine: selected, current_session_id: null, model: undefined,
|
|
535
|
+
engine_handoff: captureHandoff(latest, current) });
|
|
536
|
+
state.stats = null;
|
|
537
|
+
}
|
|
538
|
+
state.engineName = selected;
|
|
539
|
+
const handoffNote = this.config.workspaceByChat(msg.chatId)?.engine_handoff
|
|
540
|
+
? " Previous session context will be included with your next message." : "";
|
|
541
|
+
log.info("[cmd] chat %s engine → %s", msg.chatId, selected);
|
|
542
|
+
await this.channel.sendMessage(msg.chatId, `Engine set to ${selected}.${changed ? " Session cleared." : ""}${handoffNote}`);
|
|
543
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Turn audio attachments into message text. Engines can't consume audio, so it never
|
|
547
|
+
* reaches them; the echo lets the user catch a bad transcription before the agent acts.
|
|
548
|
+
*/
|
|
549
|
+
async transcribeVoice(msg) {
|
|
550
|
+
const attachments = msg.attachments ?? [];
|
|
551
|
+
const rest = attachments.filter((att) => !isAudio(att));
|
|
552
|
+
let spoken;
|
|
553
|
+
try {
|
|
554
|
+
const transcripts = [];
|
|
555
|
+
for (const att of attachments.filter(isAudio))
|
|
556
|
+
transcripts.push(await this.transcribe(att));
|
|
557
|
+
spoken = transcripts.join("\n");
|
|
558
|
+
const echo = spoken.length > 1000 ? `${spoken.slice(0, 1000)}…` : spoken;
|
|
559
|
+
await this.channel.sendMessage(msg.chatId, `🎙 heard: ${echo}`, { format: "plain" })
|
|
560
|
+
.catch((err) => log.warn({ err }, "[orchestrator] failed to echo transcript"));
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
log.warn({ err }, "[orchestrator] voice transcription failed");
|
|
564
|
+
spoken = "[Voice message — transcription failed]";
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
...msg,
|
|
568
|
+
text: msg.text ? `${msg.text}\n${spoken}` : spoken,
|
|
569
|
+
attachments: rest.length ? rest : undefined,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
231
572
|
async routeMessage(msg) {
|
|
232
573
|
try {
|
|
233
|
-
|
|
574
|
+
// routeMessage only handles channel-emitted messages, which are always user-originated.
|
|
575
|
+
if (msg.origin.kind !== "user")
|
|
576
|
+
return; // unreachable; documents + type-narrows the invariant
|
|
577
|
+
const { user } = msg.origin;
|
|
578
|
+
if (!this.config.isAuthorized(user.id))
|
|
579
|
+
return;
|
|
580
|
+
if (msg.attachments?.some(isAudio))
|
|
581
|
+
msg = await this.transcribeVoice(msg);
|
|
582
|
+
if (this.channel.isRootDM(msg.chatId, user.id) && !this.config.workspaceByChat(msg.chatId)) {
|
|
583
|
+
this.config.connectHomeWorkspace(msg.chatId);
|
|
584
|
+
}
|
|
585
|
+
const connect = msg.text.match(/^\/connect(?:\s+(\S+))?$/);
|
|
586
|
+
if (connect) {
|
|
587
|
+
await this.connectWorkspace(msg, connect[1]);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
234
590
|
const state = this.chat(msg.chatId);
|
|
235
|
-
//
|
|
591
|
+
// Native controls run without a model call.
|
|
236
592
|
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
593
|
if (state.abort) {
|
|
247
594
|
state.abort.abort();
|
|
248
595
|
log.info("[cmd] turn cancelled");
|
|
@@ -252,16 +599,10 @@ export class Orchestrator {
|
|
|
252
599
|
}
|
|
253
600
|
return;
|
|
254
601
|
}
|
|
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
602
|
const ws = this.config.workspaceByChat(msg.chatId);
|
|
262
603
|
// /mode — switch permission mode (works even during active turns)
|
|
263
604
|
if (msg.text === "/mode") {
|
|
264
|
-
const currentMode = state.permissionMode ?? this.
|
|
605
|
+
const currentMode = state.permissionMode ?? this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
|
|
265
606
|
const buttons = [
|
|
266
607
|
MODE_OPTIONS.slice(0, 2).map((opt) => ({
|
|
267
608
|
label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
|
|
@@ -289,9 +630,38 @@ export class Orchestrator {
|
|
|
289
630
|
this.config.clearSession(ws.name);
|
|
290
631
|
state.permissionMode = null;
|
|
291
632
|
state.stats = null;
|
|
292
|
-
await this.updateStatusMessage(msg.chatId, state);
|
|
293
633
|
log.info("[cmd] session cleared for workspace %s", ws.name);
|
|
294
634
|
await this.channel.sendMessage(msg.chatId, "Session cleared.");
|
|
635
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
// /recap — replay current session history without running a model turn.
|
|
639
|
+
if (msg.text === "/recap") {
|
|
640
|
+
if (!ws) {
|
|
641
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.", { consumeTyping: false });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const sessionId = ws.current_session_id;
|
|
645
|
+
if (!sessionId) {
|
|
646
|
+
await this.channel.sendMessage(msg.chatId, "No current session to recap yet.", { consumeTyping: false });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const history = await this.engineFor(ws).getSessionMessages({
|
|
651
|
+
sessionId, cwd: ws.cwd, signal: AbortSignal.timeout(30_000),
|
|
652
|
+
});
|
|
653
|
+
const current = this.config.workspaceByChat(msg.chatId);
|
|
654
|
+
if (!current || current.name !== ws.name || current.current_session_id !== sessionId
|
|
655
|
+
|| current.engine !== ws.engine) {
|
|
656
|
+
await this.channel.sendMessage(msg.chatId, "Session changed while loading history. Run /recap again.", { consumeTyping: false });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
await this.channel.sendMessage(msg.chatId, formatSessionRecap(history), { consumeTyping: false });
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
log.warn({ err }, "[recap] failed to load history for workspace %s", ws.name);
|
|
663
|
+
await this.channel.sendMessage(msg.chatId, "Couldn’t load this session’s history. Try /recap again.", { consumeTyping: false });
|
|
664
|
+
}
|
|
295
665
|
return;
|
|
296
666
|
}
|
|
297
667
|
// /resume — switch to a previous session
|
|
@@ -310,15 +680,16 @@ export class Orchestrator {
|
|
|
310
680
|
return;
|
|
311
681
|
}
|
|
312
682
|
const MAX_BTN = 45;
|
|
313
|
-
//
|
|
683
|
+
// SDK summaries can contain the session's latest prompt, including
|
|
684
|
+
// ClearClaw's bracketed framing. Drop that framing for display.
|
|
314
685
|
const stripped = sessions.map((s) => ({
|
|
315
686
|
...s,
|
|
316
|
-
summary: s.summary
|
|
687
|
+
summary: stripPromptPrefix(s.summary),
|
|
317
688
|
}));
|
|
318
689
|
// Build detailed list for message body
|
|
319
690
|
const listing = stripped.map((s, i) => {
|
|
320
691
|
const current = s.sessionId === ws.current_session_id ? " ✅" : "";
|
|
321
|
-
const meta = [timeAgo(s.lastModified), s.gitBranch].filter(Boolean).join(" · ");
|
|
692
|
+
const meta = [s.lastModified ? timeAgo(s.lastModified) : "Unknown activity", s.gitBranch].filter(Boolean).join(" · ");
|
|
322
693
|
return `${i + 1}. ${s.summary}${current}\n ${meta}`;
|
|
323
694
|
}).join("\n");
|
|
324
695
|
// Concise button labels (single line)
|
|
@@ -331,7 +702,9 @@ export class Orchestrator {
|
|
|
331
702
|
});
|
|
332
703
|
const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
|
|
333
704
|
if (resp.value) {
|
|
334
|
-
this.config.
|
|
705
|
+
const current = this.config.workspaceByName(ws.name);
|
|
706
|
+
if (current)
|
|
707
|
+
this.config.upsertWorkspace({ ...current, current_session_id: resp.value, engine_handoff: undefined });
|
|
335
708
|
const picked = stripped.find((s) => s.sessionId === resp.value);
|
|
336
709
|
await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
|
|
337
710
|
log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
|
|
@@ -358,27 +731,41 @@ export class Orchestrator {
|
|
|
358
731
|
}
|
|
359
732
|
return;
|
|
360
733
|
}
|
|
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);
|
|
734
|
+
// /engine [name] — select the workspace engine.
|
|
735
|
+
const engineMatch = msg.text.match(/^\/engine(?:\s+(\S+))?$/);
|
|
736
|
+
if (engineMatch) {
|
|
737
|
+
if (!this.config.isAuthorized(user.id)) {
|
|
738
|
+
await this.channel.sendMessage(msg.chatId, "Not authorized to select an engine.");
|
|
739
|
+
return;
|
|
377
740
|
}
|
|
378
|
-
|
|
379
|
-
|
|
741
|
+
await this.selectEngine(msg, engineMatch[1]);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// /model [name] — show or set the per-workspace model override
|
|
745
|
+
const modelMatch = msg.text.match(/^\/model(?:\s+(\S+))?$/);
|
|
746
|
+
if (modelMatch) {
|
|
747
|
+
if (!ws) {
|
|
380
748
|
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const requested = modelMatch[1];
|
|
752
|
+
if (!requested) {
|
|
753
|
+
await this.channel.sendMessage(msg.chatId, ws.model ? `Saved model: ${ws.model}` : "No model override set. New sessions use the engine's default.");
|
|
754
|
+
return;
|
|
381
755
|
}
|
|
756
|
+
if (requested === "default") {
|
|
757
|
+
this.config.setModel(ws.name, undefined);
|
|
758
|
+
await this.channel.sendMessage(msg.chatId, "Model override cleared. Your current session is kept; use /new to start with the engine's default.");
|
|
759
|
+
log.info("[cmd] workspace %s model override cleared", ws.name);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
this.config.setModel(ws.name, requested);
|
|
763
|
+
await this.channel.sendMessage(msg.chatId, `Model set to ${requested} for the next turn.`);
|
|
764
|
+
log.info("[cmd] workspace %s model → %s", ws.name, requested);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (!ws) {
|
|
768
|
+
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
769
|
return;
|
|
383
770
|
}
|
|
384
771
|
this.enqueueMessage(msg, ws, state);
|
|
@@ -402,39 +789,15 @@ export class Orchestrator {
|
|
|
402
789
|
case "text_chunk": {
|
|
403
790
|
if (turnState.staySilent)
|
|
404
791
|
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
|
-
}
|
|
792
|
+
await this.routeTextChunk(chatId, event.text, state, turnState);
|
|
426
793
|
break;
|
|
427
794
|
}
|
|
428
795
|
case "tool_use": {
|
|
429
796
|
// 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
|
-
}
|
|
797
|
+
await this.flushTextSegment(chatId, state, turnState);
|
|
436
798
|
state.textHandle = null;
|
|
437
799
|
state.textBuffer = "";
|
|
800
|
+
state.textPublishedLength = 0;
|
|
438
801
|
// In assistant behavior, all tool status is suppressed (no rolling status, no plan mode notifications)
|
|
439
802
|
if (behavior === "assistant")
|
|
440
803
|
break;
|
|
@@ -481,16 +844,10 @@ export class Orchestrator {
|
|
|
481
844
|
}
|
|
482
845
|
case "tool_result":
|
|
483
846
|
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
|
-
}
|
|
847
|
+
case "plan_usage":
|
|
848
|
+
break; // Captured account-wide in executeTurn.
|
|
492
849
|
case "done":
|
|
493
|
-
// Handled inline in executeTurn
|
|
850
|
+
// Handled inline in executeTurn for session persistence.
|
|
494
851
|
break;
|
|
495
852
|
case "error":
|
|
496
853
|
log.error(`[turn] error: ${event.message}`);
|
|
@@ -504,6 +861,66 @@ export class Orchestrator {
|
|
|
504
861
|
log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
|
|
505
862
|
}
|
|
506
863
|
}
|
|
864
|
+
async routeTextChunk(chatId, text, state, turnState) {
|
|
865
|
+
state.textBuffer += text;
|
|
866
|
+
while (state.textBuffer.length > STREAM_EDIT_LIMIT) {
|
|
867
|
+
const splitAt = streamSplitPoint(state.textBuffer, STREAM_EDIT_LIMIT);
|
|
868
|
+
const segment = state.textBuffer.slice(0, splitAt);
|
|
869
|
+
state.textBuffer = state.textBuffer.slice(state.textBuffer.charAt(splitAt) === "\n" ? splitAt + 1 : splitAt);
|
|
870
|
+
await this.publishTextSegment(chatId, segment, state, turnState, true);
|
|
871
|
+
state.textHandle = null;
|
|
872
|
+
state.textPublishedLength = 0;
|
|
873
|
+
}
|
|
874
|
+
if (state.textBuffer.length > 0
|
|
875
|
+
&& (state.textHandle
|
|
876
|
+
? state.textBuffer.length - state.textPublishedLength >= STREAM_EDIT_DELTA
|
|
877
|
+
: state.textBuffer.length >= STREAM_INITIAL_EDIT_MIN)) {
|
|
878
|
+
await this.publishTextSegment(chatId, state.textBuffer, state, turnState, false);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async publishTextSegment(chatId, text, state, turnState, final) {
|
|
882
|
+
const plainOpts = { format: "plain" };
|
|
883
|
+
if (state.textHandle) {
|
|
884
|
+
try {
|
|
885
|
+
await this.channel.editMessage(chatId, state.textHandle, text, final ? undefined : plainOpts);
|
|
886
|
+
state.textPublishedLength = text.length;
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
if (isUnchangedMessageError(err)) {
|
|
891
|
+
state.textPublishedLength = text.length;
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (isTransientChannelError(err))
|
|
895
|
+
return;
|
|
896
|
+
state.textHandle = null;
|
|
897
|
+
state.textPublishedLength = 0;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const handles = await this.channel.sendMessage(chatId, text, {
|
|
901
|
+
...(final ? {} : plainOpts),
|
|
902
|
+
replyToMessageId: !state.textReplySent ? (turnState.replyToMessageId ?? undefined) : undefined,
|
|
903
|
+
});
|
|
904
|
+
state.textHandle = handles[0] ?? null;
|
|
905
|
+
state.textPublishedLength = text.length;
|
|
906
|
+
state.textReplySent = true;
|
|
907
|
+
}
|
|
908
|
+
async flushTextSegment(chatId, state, turnState) {
|
|
909
|
+
if (!state.textBuffer)
|
|
910
|
+
return;
|
|
911
|
+
if (!state.textHandle) {
|
|
912
|
+
try {
|
|
913
|
+
await this.publishTextSegment(chatId, state.textBuffer, state, turnState, true);
|
|
914
|
+
}
|
|
915
|
+
catch { /* best effort */ }
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
await this.channel.editMessage(chatId, state.textHandle, state.textBuffer);
|
|
920
|
+
state.textPublishedLength = state.textBuffer.length;
|
|
921
|
+
}
|
|
922
|
+
catch { /* best effort */ }
|
|
923
|
+
}
|
|
507
924
|
buildMcpTools(chatId, behavior, turnState) {
|
|
508
925
|
const tools = [
|
|
509
926
|
tool("send_file", "Send a file or image to the current chat conversation", {
|
|
@@ -522,39 +939,234 @@ export class Orchestrator {
|
|
|
522
939
|
return { content: [{ type: "text", text: `Sent ${name} to chat` }] };
|
|
523
940
|
}),
|
|
524
941
|
];
|
|
525
|
-
//
|
|
526
|
-
if (this.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
engine: z.string().optional()
|
|
533
|
-
.describe("Engine to use (e.g. 'claude-code', 'kiro'). Defaults to the server's configured default engine."),
|
|
942
|
+
// Scheduled prompts run in normal workspace sessions.
|
|
943
|
+
if (this.scheduler) {
|
|
944
|
+
const sched = this.scheduler;
|
|
945
|
+
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.", {
|
|
946
|
+
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."),
|
|
947
|
+
prompt: z.string().describe("The prompt text to run on schedule"),
|
|
948
|
+
timezone: z.string().optional().describe("IANA timezone (e.g. 'America/Los_Angeles'). Defaults to system timezone"),
|
|
534
949
|
}, async (args) => {
|
|
535
|
-
|
|
536
|
-
|
|
950
|
+
const entry = {
|
|
951
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
952
|
+
cron: args.cron,
|
|
953
|
+
prompt: args.prompt,
|
|
954
|
+
enabled: true,
|
|
955
|
+
timezone: args.timezone,
|
|
956
|
+
createdAt: Date.now(),
|
|
957
|
+
};
|
|
958
|
+
sched.add(entry);
|
|
959
|
+
const isDate = !isNaN(new Date(args.cron).getTime());
|
|
960
|
+
return { content: [{ type: "text", text: `Schedule "${entry.id}" created: ${args.cron}${args.timezone ? ` (${args.timezone})` : ""}${isDate ? " [one-off]" : ""}` }] };
|
|
961
|
+
}), tool("schedule_list", "List all scheduled prompts", {}, async () => {
|
|
962
|
+
const entries = sched.list();
|
|
963
|
+
if (entries.length === 0) {
|
|
964
|
+
return { content: [{ type: "text", text: "No schedules configured." }] };
|
|
537
965
|
}
|
|
538
|
-
|
|
539
|
-
|
|
966
|
+
const lines = entries.map((e) => `• ${e.id} — ${e.enabled ? "✓" : "✗"} ${e.cron}${e.timezone ? ` (${e.timezone})` : ""}\n ${e.prompt.slice(0, 80)}`);
|
|
967
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
968
|
+
}), tool("schedule_delete", "Delete a scheduled prompt", {
|
|
969
|
+
id: z.string().describe("Schedule ID to delete"),
|
|
970
|
+
}, async (args) => {
|
|
971
|
+
sched.remove(args.id);
|
|
972
|
+
return { content: [{ type: "text", text: `Schedule "${args.id}" deleted.` }] };
|
|
973
|
+
}), tool("schedule_toggle", "Enable or disable a scheduled prompt", {
|
|
974
|
+
id: z.string().describe("Schedule ID to toggle"),
|
|
975
|
+
enabled: z.boolean().describe("Whether to enable or disable the schedule"),
|
|
976
|
+
}, async (args) => {
|
|
977
|
+
sched.toggle(args.id, args.enabled);
|
|
978
|
+
return { content: [{ type: "text", text: `Schedule "${args.id}" ${args.enabled ? "enabled" : "disabled"}.` }] };
|
|
979
|
+
}));
|
|
980
|
+
}
|
|
981
|
+
// Workspace tools are available in every workspace conversation.
|
|
982
|
+
if (this.config.workspaceByChat(chatId)) {
|
|
983
|
+
const self = this.config.workspaceByChat(chatId);
|
|
984
|
+
const peers = this.config.listWorkspaces().filter((w) => w.name !== self?.name);
|
|
985
|
+
const projectNames = this.config.listProjects().map((p) => `"${p.name}"`).join(", ") || "(none)";
|
|
986
|
+
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.", {
|
|
987
|
+
project: z.string().optional().describe("Only list workspaces in this project; omit for all projects"),
|
|
988
|
+
}, async (args) => {
|
|
989
|
+
const all = this.config.listWorkspaces();
|
|
990
|
+
const listed = args.project ? all.filter((w) => w.project === args.project) : all;
|
|
991
|
+
if (listed.length === 0) {
|
|
992
|
+
const known = this.config.listProjects().map((p) => p.name).join(", ") || "(none)";
|
|
993
|
+
return { content: [{ type: "text", text: args.project
|
|
994
|
+
? `No workspaces in project "${args.project}". Known projects: ${known}.`
|
|
995
|
+
: "No workspaces." }] };
|
|
540
996
|
}
|
|
541
|
-
|
|
542
|
-
|
|
997
|
+
const lines = listed
|
|
998
|
+
.map((w) => [
|
|
999
|
+
w.name === self?.name ? `${w.name} (you)` : w.name,
|
|
1000
|
+
`project: ${w.project ?? "(none)"}`,
|
|
1001
|
+
w.chat_id ? "connected" : "awaiting /connect",
|
|
1002
|
+
w.description ? `— ${w.description}` : "",
|
|
1003
|
+
].filter(Boolean).join(" | "))
|
|
1004
|
+
.sort();
|
|
1005
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1006
|
+
}), 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.", {
|
|
1007
|
+
workspace: z.string().describe("Target workspace name; see list_workspaces"),
|
|
1008
|
+
message: z.string().describe("The message to send"),
|
|
1009
|
+
summary: z.string().describe("One-line gist shown in both chats in place of the full message"),
|
|
1010
|
+
}, async (args) => {
|
|
1011
|
+
const target = this.config.workspaceByName(args.workspace);
|
|
1012
|
+
if (!target) {
|
|
1013
|
+
return { content: [{ type: "text", text: `No workspace named "${args.workspace}". Call list_workspaces to see the ${peers.length} reachable.` }] };
|
|
1014
|
+
}
|
|
1015
|
+
if (self && target.name === self.name) {
|
|
1016
|
+
return { content: [{ type: "text", text: "Cannot message yourself." }] };
|
|
1017
|
+
}
|
|
1018
|
+
const fromName = self ? self.name : "unknown";
|
|
1019
|
+
const ok = this.deliverToWorkspace(target.name, { kind: "peer", workspaceName: fromName }, args.message);
|
|
1020
|
+
if (!ok) {
|
|
1021
|
+
return { content: [{ type: "text", text: `Failed to deliver to "${target.name}".` }] };
|
|
1022
|
+
}
|
|
1023
|
+
await this.channel.sendMessage(chatId, `→ sent to ${target.name}: ${args.summary}`);
|
|
1024
|
+
await this.channel.sendMessage(target.chat_id, `← from ${fromName}: ${args.summary}`).catch(() => { });
|
|
1025
|
+
log.info("[tool] message_workspace: %s → %s", fromName, target.name);
|
|
1026
|
+
return { content: [{ type: "text", text: `Delivered to ${target.name}.` }] };
|
|
1027
|
+
}), 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}.`, {
|
|
1028
|
+
name: z.string().describe("New Project name"),
|
|
1029
|
+
description: z.string().describe("What the Project is about"),
|
|
1030
|
+
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"),
|
|
1031
|
+
}, async (args) => {
|
|
1032
|
+
if (this.config.projectByName(args.name)) {
|
|
1033
|
+
return { content: [{ type: "text", text: `Project "${args.name}" already exists.` }] };
|
|
1034
|
+
}
|
|
1035
|
+
const mainName = args.main_workspace ?? self?.name;
|
|
1036
|
+
if (!mainName) {
|
|
1037
|
+
return { content: [{ type: "text", text: "No current workspace to use as the Project main." }] };
|
|
1038
|
+
}
|
|
1039
|
+
const main = this.config.workspaceByName(mainName);
|
|
1040
|
+
if (!main) {
|
|
1041
|
+
return { content: [{ type: "text", text: `No workspace named "${mainName}".` }] };
|
|
1042
|
+
}
|
|
1043
|
+
// A peer can be adopted out of its project; its main cannot — that would orphan the project.
|
|
1044
|
+
const currentProject = this.config.listProjects().find((p) => p.main_workspace === main.name);
|
|
1045
|
+
if (currentProject) {
|
|
1046
|
+
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.` }] };
|
|
1047
|
+
}
|
|
1048
|
+
this.config.upsertWorkspace({ ...main, project: args.name });
|
|
1049
|
+
this.config.addProject({
|
|
543
1050
|
name: args.name,
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1051
|
+
description: args.description,
|
|
1052
|
+
main_workspace: main.name,
|
|
1053
|
+
});
|
|
1054
|
+
const grouping = main.chat_id ? await this.groupChat(args.name, main.chat_id) : undefined;
|
|
1055
|
+
log.info("[tool] project_create: %s (main %s)", args.name, main.name);
|
|
1056
|
+
return { content: [{ type: "text", text: `Project "${args.name}" created with "${main.name}" as its main workspace.${grouping ? `\n\n${grouping}` : ""}` }] };
|
|
1057
|
+
}), 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.`, {
|
|
1058
|
+
name: z.string().min(1).describe("Unique short workspace name"),
|
|
1059
|
+
cwd: z.string().min(1).describe("Absolute path to a directory you have already prepared"),
|
|
1060
|
+
brief: z.string().min(1).describe("Goal, agreed decisions, and scope; delivered as the peer's first message"),
|
|
1061
|
+
description: z.string().optional().describe("One-line focus; defaults to the first 160 characters of the brief's first line"),
|
|
1062
|
+
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"),
|
|
1063
|
+
behavior: z.enum(["assistant", "relay"]).optional(),
|
|
1064
|
+
engine: z.string().optional(),
|
|
1065
|
+
model: z.string().optional(),
|
|
1066
|
+
}, async (args) => {
|
|
1067
|
+
const currentSelf = this.config.workspaceByChat(chatId);
|
|
1068
|
+
if (!currentSelf)
|
|
1069
|
+
return { content: [{ type: "text", text: "The source workspace is no longer connected." }] };
|
|
1070
|
+
// An unknown name creates that project; a legacy caller with none falls back to the workspace name.
|
|
1071
|
+
const requested = args.project ?? currentSelf.project;
|
|
1072
|
+
const project = requested ? this.config.projectByName(requested) : undefined;
|
|
1073
|
+
const newProjectName = project ? undefined : requested ?? args.name;
|
|
1074
|
+
const base = project ? this.config.workspaceByName(project.main_workspace) : currentSelf;
|
|
1075
|
+
if (!base)
|
|
1076
|
+
return { content: [{ type: "text", text: `Project "${project.name}" has no main workspace.` }] };
|
|
1077
|
+
const resolved = this.peerRuntime(base, args);
|
|
1078
|
+
if (resolved.error)
|
|
1079
|
+
return { content: [{ type: "text", text: resolved.error }] };
|
|
1080
|
+
const invalid = this.validateNewWorkspace(args, newProjectName);
|
|
1081
|
+
if (invalid)
|
|
1082
|
+
return { content: [{ type: "text", text: invalid }] };
|
|
1083
|
+
const runtime = resolved.runtime;
|
|
1084
|
+
const automatic = !!(this.channel.createProjectChat && this.channel.closeProjectChat
|
|
1085
|
+
&& (!base.chat_id || this.channel.ownsId(base.chat_id)));
|
|
1086
|
+
const destination = project ? `project "${project.name}"` : `new project "${newProjectName}"`;
|
|
1087
|
+
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" }] : []),
|
|
1088
|
+
{ label: "Manual chat", value: "manual" }, { label: "Cancel", value: "cancel" }]]);
|
|
1089
|
+
if (response.value !== "spawn" && response.value !== "manual")
|
|
1090
|
+
return { content: [{ type: "text", text: "Workspace creation cancelled." }] };
|
|
1091
|
+
return this.createWorkspace(chatId, currentSelf.name, project, newProjectName, base, { ...args, manual: response.value === "manual" }, runtime);
|
|
1092
|
+
}), 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'.", {
|
|
1093
|
+
name: z.string().describe("Workspace to archive"),
|
|
1094
|
+
}, async (args) => {
|
|
1095
|
+
if (args.name === "default") {
|
|
1096
|
+
return { content: [{ type: "text", text: "Cannot archive the home workspace." }] };
|
|
1097
|
+
}
|
|
1098
|
+
const target = this.config.workspaceByName(args.name);
|
|
1099
|
+
if (!target) {
|
|
1100
|
+
return { content: [{ type: "text", text: `No workspace named "${args.name}".` }] };
|
|
1101
|
+
}
|
|
1102
|
+
const project = target.project ? this.config.projectByName(target.project) : undefined;
|
|
1103
|
+
if (project?.main_workspace === args.name) {
|
|
1104
|
+
const peers = this.config.listWorkspaces().filter((w) => w.project === project.name && w.name !== args.name);
|
|
1105
|
+
if (peers.length > 0) {
|
|
1106
|
+
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.` }] };
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
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" }]]);
|
|
1110
|
+
if (resp.value !== "yes") {
|
|
1111
|
+
return { content: [{ type: "text", text: "Archive cancelled by the user." }] };
|
|
1112
|
+
}
|
|
1113
|
+
if (target.chat_id && (!this.channel.ownsId(target.chat_id) || !this.channel.closeProjectChat)) {
|
|
1114
|
+
return { content: [{ type: "text", text: "Cannot archive: this channel cannot close the workspace's chat." }] };
|
|
1115
|
+
}
|
|
1116
|
+
// Whole Telegram chats and deleted topics can be impossible to close.
|
|
1117
|
+
let archiveNote = "";
|
|
1118
|
+
try {
|
|
1119
|
+
if (target.chat_id)
|
|
1120
|
+
await this.channel.closeProjectChat(target.chat_id, target.project);
|
|
1121
|
+
}
|
|
1122
|
+
catch (err) {
|
|
1123
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1124
|
+
log.warn("[tool] workspace_archive: chat closure failed, unbinding anyway: %s", reason);
|
|
1125
|
+
archiveNote = ` Chat closure failed (${reason}).`;
|
|
1126
|
+
}
|
|
1127
|
+
this.config.removeWorkspace(args.name);
|
|
1128
|
+
const removedProject = project?.main_workspace === args.name ? project : undefined;
|
|
1129
|
+
if (removedProject)
|
|
1130
|
+
this.config.removeProject(removedProject.name);
|
|
1131
|
+
archiveNote += " Its directory is left in place; clean up with your own tooling.";
|
|
1132
|
+
log.info("[tool] workspace_archive: %s", args.name);
|
|
1133
|
+
return { content: [{ type: "text", text: `Workspace "${args.name}" archived.${archiveNote}` }] };
|
|
1134
|
+
}), tool("project_update", "Update a project's shared context: its description, or reassign its main workspace.", {
|
|
1135
|
+
name: z.string().describe("Project name to update"),
|
|
1136
|
+
description: z.string().optional().describe("New description (what the project is about)"),
|
|
1137
|
+
main_workspace: z.string().optional().describe("Reassign the project's main workspace"),
|
|
1138
|
+
}, async (args) => {
|
|
1139
|
+
const proj = this.config.projectByName(args.name);
|
|
1140
|
+
if (!proj) {
|
|
1141
|
+
return { content: [{ type: "text", text: `No project named "${args.name}".` }] };
|
|
1142
|
+
}
|
|
1143
|
+
if (args.main_workspace && !this.config.workspaceByName(args.main_workspace)) {
|
|
1144
|
+
return { content: [{ type: "text", text: `No workspace named "${args.main_workspace}".` }] };
|
|
1145
|
+
}
|
|
1146
|
+
if (args.main_workspace && this.config.workspaceByName(args.main_workspace)?.project !== proj.name) {
|
|
1147
|
+
return { content: [{ type: "text", text: `Choose a main workspace that already belongs to project "${proj.name}".` }] };
|
|
1148
|
+
}
|
|
1149
|
+
this.config.addProject({
|
|
1150
|
+
name: proj.name,
|
|
1151
|
+
description: args.description ?? proj.description,
|
|
1152
|
+
main_workspace: args.main_workspace ?? proj.main_workspace,
|
|
549
1153
|
});
|
|
550
|
-
log.info("[tool]
|
|
551
|
-
return { content: [{ type: "text", text: `
|
|
552
|
-
}), tool("
|
|
553
|
-
|
|
1154
|
+
log.info("[tool] project_update: %s", args.name);
|
|
1155
|
+
return { content: [{ type: "text", text: `Project "${args.name}" updated.` }] };
|
|
1156
|
+
}), 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.", {
|
|
1157
|
+
name: z.string().describe("Workspace to update"),
|
|
1158
|
+
description: z.string().optional().describe("What this workspace is currently working on"),
|
|
554
1159
|
}, async (args) => {
|
|
555
|
-
this.
|
|
556
|
-
|
|
557
|
-
|
|
1160
|
+
const ws = this.config.workspaceByName(args.name);
|
|
1161
|
+
if (!ws) {
|
|
1162
|
+
return { content: [{ type: "text", text: `No workspace named "${args.name}".` }] };
|
|
1163
|
+
}
|
|
1164
|
+
this.config.upsertWorkspace({
|
|
1165
|
+
...ws,
|
|
1166
|
+
description: args.description ?? ws.description,
|
|
1167
|
+
});
|
|
1168
|
+
log.info("[tool] workspace_update: %s", args.name);
|
|
1169
|
+
return { content: [{ type: "text", text: `Workspace "${args.name}" updated.` }] };
|
|
558
1170
|
}));
|
|
559
1171
|
}
|
|
560
1172
|
if (behavior === "assistant") {
|
|
@@ -617,31 +1229,45 @@ export class Orchestrator {
|
|
|
617
1229
|
}
|
|
618
1230
|
}
|
|
619
1231
|
async updateStatusMessage(chatId, state) {
|
|
620
|
-
const
|
|
1232
|
+
const ws = this.config.workspaceByChat(chatId);
|
|
1233
|
+
const baseMode = this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
|
|
1234
|
+
const mode = state.permissionMode ?? baseMode;
|
|
621
1235
|
const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
|
|
622
1236
|
let text;
|
|
623
1237
|
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}`;
|
|
1238
|
+
const usage = state.stats.contextWindow > 0
|
|
1239
|
+
? `${Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)}%`
|
|
1240
|
+
: "usage n/a";
|
|
1241
|
+
const displayName = state.stats.modelLabel ?? state.stats.model;
|
|
1242
|
+
text = `🤖 ${displayName ? `${displayName} ` : ""}${usage}`;
|
|
631
1243
|
}
|
|
632
1244
|
else {
|
|
633
|
-
text =
|
|
1245
|
+
text = "";
|
|
634
1246
|
}
|
|
1247
|
+
if (mode !== baseMode)
|
|
1248
|
+
text += `${text ? " | " : ""}🔒 ${modeLabel}`;
|
|
1249
|
+
const engine = ws?.engine ?? state.engineName ?? this.config.defaultEngine;
|
|
1250
|
+
text += `${text ? " | " : ""}${engine}`;
|
|
1251
|
+
const maxLength = this.channel.statusMaxLength ?? 4096;
|
|
1252
|
+
if (text.length > maxLength - 80)
|
|
1253
|
+
text = `${text.slice(0, maxLength - 81)}…`;
|
|
1254
|
+
const usage = this.planUsage.get(engine) ?? {
|
|
1255
|
+
windows: new Map(this.engines.get(engine)?.planUsageWindows?.map((window) => [window.id, window])),
|
|
1256
|
+
};
|
|
1257
|
+
const quota = formatPlanUsage(usage, maxLength - text.length - 3);
|
|
1258
|
+
if (quota)
|
|
1259
|
+
text += `${text ? " | " : ""}${quota}`;
|
|
635
1260
|
if (text === state.lastStatusText)
|
|
636
1261
|
return;
|
|
637
|
-
|
|
638
|
-
|
|
1262
|
+
try {
|
|
1263
|
+
await this.channel.updateStatus(chatId, text);
|
|
1264
|
+
state.lastStatusText = text;
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
log.warn({ err }, "[status] failed to update status for chat %s", chatId);
|
|
1268
|
+
}
|
|
639
1269
|
}
|
|
640
1270
|
}
|
|
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
1271
|
/** Format reply context as a bracketed prefix line for the LLM prompt. */
|
|
646
1272
|
function formatReplyLine(replyTo) {
|
|
647
1273
|
if (!replyTo)
|
|
@@ -660,15 +1286,76 @@ function formatReplyLine(replyTo) {
|
|
|
660
1286
|
* Build the turn prompt: `[msg:N] sender: text`, one line per message.
|
|
661
1287
|
* Works for both single-message (relay) and batched (assistant) turns.
|
|
662
1288
|
*/
|
|
663
|
-
function
|
|
1289
|
+
function formatTimestamp() {
|
|
1290
|
+
const now = new Date();
|
|
1291
|
+
const day = now.toLocaleDateString("en-US", { weekday: "short" });
|
|
1292
|
+
const date = now.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
|
1293
|
+
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short" });
|
|
1294
|
+
return `${date} ${day} ${time}`;
|
|
1295
|
+
}
|
|
1296
|
+
function senderLabel(origin) {
|
|
1297
|
+
switch (origin.kind) {
|
|
1298
|
+
// Keep the ID inside the brackets, because stripPromptPrefix stops at the first colon outside them.
|
|
1299
|
+
case "user": return `[user ${origin.user.id}] ${origin.user.handle ? `${origin.user.name} (@${origin.user.handle})` : origin.user.name}`;
|
|
1300
|
+
case "scheduler": return `[scheduler] ${origin.scheduleId}`;
|
|
1301
|
+
case "peer": return `[peer] ${origin.workspaceName}`;
|
|
1302
|
+
default: {
|
|
1303
|
+
const _exhaustive = origin;
|
|
1304
|
+
return _exhaustive;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* A lone slash-command message (built-in like `/compact`, `/context`, or a
|
|
1310
|
+
* custom `.claude/commands/<name>`) must reach the engine verbatim: the CLI
|
|
1311
|
+
* only intercepts a slash command when it sits at position 0 of the prompt.
|
|
1312
|
+
* buildPrompt's `[ts] sender:` framing would push it off position 0, so the
|
|
1313
|
+
* command would be sent to the model as plain text instead of executed.
|
|
1314
|
+
*
|
|
1315
|
+
* ClearClaw's own commands (/new, /resume, /behavior, …) are handled and
|
|
1316
|
+
* returned before the turn starts, so any leading-slash message that reaches
|
|
1317
|
+
* here is a pass-through command. Restricted to single, user-typed messages —
|
|
1318
|
+
* a slash buried in a batch, or in a scheduler/peer prompt, is not a command.
|
|
1319
|
+
* Returns the bare command text, or null to fall back to buildPrompt.
|
|
1320
|
+
*/
|
|
1321
|
+
function slashCommandPrompt(messages) {
|
|
1322
|
+
if (messages.length !== 1)
|
|
1323
|
+
return null;
|
|
1324
|
+
const msg = messages[0];
|
|
1325
|
+
if (msg.origin.kind !== "user")
|
|
1326
|
+
return null;
|
|
1327
|
+
const text = msg.text.trim();
|
|
1328
|
+
return text.startsWith("/") ? text : null;
|
|
1329
|
+
}
|
|
1330
|
+
function streamSplitPoint(text, limit) {
|
|
1331
|
+
if (text.length <= limit)
|
|
1332
|
+
return text.length;
|
|
1333
|
+
const newlineAt = text.lastIndexOf("\n", limit);
|
|
1334
|
+
return newlineAt > 0 ? newlineAt : limit;
|
|
1335
|
+
}
|
|
1336
|
+
function isTransientChannelError(err) {
|
|
1337
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1338
|
+
return message.includes("Too Many Requests") || message.includes("429:");
|
|
1339
|
+
}
|
|
1340
|
+
function isUnchangedMessageError(err) {
|
|
1341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1342
|
+
return message.includes("message is not modified");
|
|
1343
|
+
}
|
|
1344
|
+
export function buildPrompt(messages) {
|
|
1345
|
+
const ts = formatTimestamp();
|
|
664
1346
|
return messages.map((msg) => {
|
|
665
|
-
const sender =
|
|
1347
|
+
const sender = senderLabel(msg.origin);
|
|
666
1348
|
const msgIdPrefix = msg.messageId ? `[msg:${msg.messageId}] ` : "";
|
|
667
1349
|
const replyLine = formatReplyLine(msg.replyTo);
|
|
668
|
-
|
|
669
|
-
? ` [${msg.attachments.length} attachment(s)]`
|
|
670
|
-
: "";
|
|
671
|
-
return `${replyLine}${msgIdPrefix}${sender}: ${msg.text}${attachmentNote}`;
|
|
1350
|
+
return `${replyLine}[${ts}] ${msgIdPrefix}${sender}: ${msg.text}`;
|
|
672
1351
|
}).join("\n");
|
|
673
1352
|
}
|
|
1353
|
+
/** Preserve the original source across multiple switches before a handoff succeeds. */
|
|
1354
|
+
function captureHandoff(ctx, engine) {
|
|
1355
|
+
const sessionId = ctx.current_session_id;
|
|
1356
|
+
return ctx.engine_handoff ?? (sessionId ? { engine, sessionId, cwd: ctx.cwd } : undefined);
|
|
1357
|
+
}
|
|
1358
|
+
function sameHandoff(left, right) {
|
|
1359
|
+
return left?.engine === right.engine && left.sessionId === right.sessionId && left.cwd === right.cwd;
|
|
1360
|
+
}
|
|
674
1361
|
//# sourceMappingURL=orchestrator.js.map
|