clearclaw 0.6.1 → 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.
@@ -1,29 +1,36 @@
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 { assemblePrompt } from "./prompt.js";
9
+ import { formatPlanUsage, recordPlanUsage } from "./plan-usage.js";
7
10
  import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, formatTodoList, timeAgo } from "./format.js";
8
11
  import { permissionHandlers, displayHandledTools } from "./tool-handlers.js";
9
- function isTask(ctx) {
10
- return "prompt" in ctx;
11
- }
12
+ import { formatSessionRecap, formatSessionTranscript, stripPromptPrefix } from "./engine/session-transcript.js";
13
+ import { Scheduler } from "./scheduler.js";
12
14
  const MODE_OPTIONS = [
13
15
  { label: "Default", value: "default" },
14
16
  { label: "Accept Edits", value: "acceptEdits" },
15
17
  { label: "Plan", value: "plan" },
16
18
  { label: "Bypass", value: "bypassPermissions" },
17
19
  ];
20
+ const STREAM_EDIT_LIMIT = 3000;
21
+ const STREAM_INITIAL_EDIT_MIN = 96;
22
+ const STREAM_EDIT_DELTA = 160;
18
23
  export class Orchestrator {
19
24
  channel;
20
- engine;
25
+ engines;
21
26
  config;
22
27
  chats = new Map();
23
- tasks = new Map();
28
+ creatingWorkspaces = new Set();
29
+ scheduler = null;
30
+ planUsage = new Map();
24
31
  constructor(opts) {
25
32
  this.channel = opts.channel;
26
- this.engine = opts.engine;
33
+ this.engines = opts.engines;
27
34
  this.config = opts.config;
28
35
  }
29
36
  chat(chatId) {
@@ -31,21 +38,37 @@ export class Orchestrator {
31
38
  if (!s) {
32
39
  s = {
33
40
  busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
34
- toolCallHandle: null, todoHandle: null,
41
+ toolCallHandle: null, todoHandle: null, textHandle: null, textBuffer: "",
42
+ textPublishedLength: 0,
43
+ textReplySent: false,
44
+ engineName: null,
35
45
  messageQueue: [], debounceTimer: null,
36
46
  };
37
47
  this.chats.set(chatId, s);
38
48
  }
39
49
  return s;
40
50
  }
51
+ engineFor(ws) {
52
+ const name = ws.engine ?? this.config.defaultEngine;
53
+ const engine = this.engines.get(name);
54
+ if (!engine) {
55
+ throw new Error(`Unknown engine "${name}" configured for workspace "${ws.name}". Available: ${[...this.engines.keys()].join(", ")}`);
56
+ }
57
+ return engine;
58
+ }
41
59
  async start() {
60
+ this.config.ensureHomeWorkspace();
42
61
  this.channel.on("message", (msg) => {
43
62
  this.routeMessage(msg).catch((err) => {
44
63
  log.error({ err }, "[orchestrator] unhandled message error");
45
64
  });
46
65
  });
47
66
  await this.channel.connect();
67
+ for (const ws of this.config.listWorkspaces())
68
+ this.deliverInitialBrief(ws.name);
48
69
  log.info("ClearClaw ready.");
70
+ this.scheduler = new Scheduler(this.config, (msg) => this.deliverToWorkspace("default", msg.origin, msg.text));
71
+ this.scheduler.start();
49
72
  const shutdown = async () => {
50
73
  await this.stop();
51
74
  process.exit(0);
@@ -54,18 +77,192 @@ export class Orchestrator {
54
77
  process.on("SIGTERM", shutdown);
55
78
  }
56
79
  async stop() {
80
+ this.scheduler?.stop();
57
81
  await this.channel.disconnect();
58
82
  }
59
- /** Effective behavior: tasks→assistant, workspace→explicit setting or home→assistant / project→relay. */
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. */
60
254
  effectiveBehavior(ctx) {
61
- if (isTask(ctx))
62
- return "assistant";
63
255
  if (ctx.behavior !== undefined)
64
256
  return ctx.behavior;
65
- return ctx.cwd === path.dirname(this.config.defaultPromptPath) ? "assistant" : "relay";
257
+ return ctx.cwd === this.config.homeWorkspacePath ? "assistant" : "relay";
258
+ }
259
+ /** Permission mode a chat runs in until /mode picks one. */
260
+ baseMode(behavior) {
261
+ return behavior === "assistant" ? "bypassPermissions" : this.config.permissionMode;
66
262
  }
67
- /** Enqueue a message and drain — immediately for relay, debounced for assistant/task. */
263
+ /** Enqueue a message and drain — immediately for relay, debounced for assistant. */
68
264
  enqueueMessage(msg, ctx, state) {
265
+ log.info("[msg] %s: %s", senderLabel(msg.origin), msg.text.slice(0, 80));
69
266
  state.messageQueue.push(msg);
70
267
  if (state.busy)
71
268
  return;
@@ -93,7 +290,7 @@ export class Orchestrator {
93
290
  const state = this.chat(chatId);
94
291
  if (state.messageQueue.length === 0 || state.busy)
95
292
  return;
96
- const ctx = this.tasks.get(chatId) ?? this.config.workspaceByChat(chatId);
293
+ const ctx = this.config.workspaceByChat(chatId);
97
294
  if (!ctx)
98
295
  return;
99
296
  const messages = [...state.messageQueue];
@@ -103,6 +300,11 @@ export class Orchestrator {
103
300
  }
104
301
  catch (err) {
105
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(() => { });
106
308
  await this.channel.sendMessage(chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
107
309
  return;
108
310
  }
@@ -119,30 +321,76 @@ export class Orchestrator {
119
321
  }
120
322
  }
121
323
  async executeTurn(chatId, messages, ctx, state) {
122
- const task = isTask(ctx) ? ctx : undefined;
123
- const ws = isTask(ctx) ? undefined : ctx;
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
+ }
124
336
  state.busy = true;
125
337
  const abort = new AbortController();
126
338
  state.abort = abort;
127
- const behavior = this.effectiveBehavior(ctx);
339
+ const behavior = messages.some((m) => m.origin.kind === "scheduler")
340
+ ? "assistant"
341
+ : this.effectiveBehavior(ctx);
128
342
  const turnState = { staySilent: false, replyToMessageId: null };
129
- const sessionId = task ? task.sessionId : ws.current_session_id;
130
- const cwd = task ? task.cwd : ws.cwd;
131
- const logPrefix = task ? "[task-turn]" : "[turn]";
343
+ const sessionId = ws.current_session_id;
344
+ const cwd = ws.cwd;
345
+ const logPrefix = "[turn]";
132
346
  log.info("%s start session=%s msgs=%d cwd=%s", logPrefix, sessionId ?? "new", messages.length, cwd);
133
347
  await this.channel.setTyping(chatId, true);
134
- const isHomeWorkspace = ws && ws.cwd === path.dirname(this.config.defaultPromptPath);
135
- const appendSystemPrompt = task
136
- ? task.prompt
137
- : (isHomeWorkspace ? undefined : this.readDefaultPrompt());
138
- const prompt = buildPrompt(messages);
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
+ }
139
382
  // Save attachments for workspace turns
140
383
  const allAttachments = messages.flatMap((m) => m.attachments ?? []);
141
384
  if (ws && allAttachments.length) {
142
385
  const results = await Promise.allSettled(allAttachments.map((att) => saveFile(att, ws.name, this.config.filesPath)));
143
- for (const r of results) {
144
- if (r.status === "rejected")
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 {
145
392
  log.warn({ err: r.reason }, "[turn] failed to save attachment");
393
+ }
146
394
  }
147
395
  const saved = results.filter((r) => r.status === "fulfilled").length;
148
396
  if (saved > 0)
@@ -152,31 +400,60 @@ export class Orchestrator {
152
400
  name: "clearclaw",
153
401
  tools: this.buildMcpTools(chatId, behavior, turnState),
154
402
  });
403
+ const engine = this.engineFor(ws);
404
+ state.engineName = engine.name;
405
+ let turnFailed = false;
155
406
  try {
156
- for await (const event of this.engine.runTurn({
407
+ abort.signal.throwIfAborted();
408
+ for await (const event of engine.runTurn({
157
409
  sessionId,
158
410
  cwd,
159
411
  prompt,
160
412
  attachments: ws && allAttachments.length > 0 ? allAttachments : undefined,
161
- permissionMode: state.permissionMode ?? (behavior === "assistant" ? "bypassPermissions" : this.config.permissionMode),
413
+ permissionMode: state.permissionMode ?? this.baseMode(behavior),
162
414
  appendSystemPrompt,
163
415
  mcpServers: { clearclaw: mcpServer },
164
416
  signal: abort.signal,
165
417
  onPermissionRequest: (req) => this.handlePermission(req, chatId),
418
+ model: ws?.model,
166
419
  })) {
167
- // Handle done event inline — task vs workspace need different session storage
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.
168
438
  if (event.type === "done") {
169
439
  log.info("%s done session=%s", logPrefix, event.sessionId);
170
- if (task) {
171
- const currentTask = this.tasks.get(chatId);
172
- if (currentTask)
173
- currentTask.sessionId = event.sessionId;
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
+ }
174
450
  }
175
- else {
176
- this.config.setSession(ws.name, event.sessionId);
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
+ }
177
456
  }
178
- if (event.stats)
179
- state.stats = event.stats;
180
457
  if (behavior === "relay" && state.toolCallHandle && event.stats) {
181
458
  const summary = formatToolCallSummary(event.stats.toolCalls);
182
459
  if (summary) {
@@ -188,6 +465,11 @@ export class Orchestrator {
188
465
  }
189
466
  state.toolCallHandle = null;
190
467
  state.todoHandle = null;
468
+ await this.flushTextSegment(chatId, state, turnState);
469
+ state.textHandle = null;
470
+ state.textBuffer = "";
471
+ state.textPublishedLength = 0;
472
+ state.textReplySent = false;
191
473
  await this.updateStatusMessage(chatId, state);
192
474
  break;
193
475
  }
@@ -201,27 +483,81 @@ export class Orchestrator {
201
483
  if (!turnState.staySilent) {
202
484
  await this.channel.setTyping(chatId, false);
203
485
  }
204
- // Task cancel already sent "Setup cancelled" from /cancel handler
205
- if (cancelled && !task) {
486
+ if (cancelled) {
206
487
  await this.channel.sendMessage(chatId, "Turn cancelled.");
207
488
  }
208
489
  }
209
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
+ }
210
542
  async routeMessage(msg) {
211
543
  try {
212
- log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
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
+ }
213
558
  const state = this.chat(msg.chatId);
214
- // /cancel — abort running turn or clear active task (must come before task routing)
559
+ // Native controls run without a model call.
215
560
  if (msg.text === "/cancel") {
216
- const task = this.tasks.get(msg.chatId);
217
- if (task) {
218
- this.tasks.delete(msg.chatId);
219
- if (state.abort)
220
- state.abort.abort();
221
- log.info("[cmd] task cancelled for chat %s", msg.chatId);
222
- await this.channel.sendMessage(msg.chatId, "Setup cancelled.");
223
- return;
224
- }
225
561
  if (state.abort) {
226
562
  state.abort.abort();
227
563
  log.info("[cmd] turn cancelled");
@@ -231,16 +567,10 @@ export class Orchestrator {
231
567
  }
232
568
  return;
233
569
  }
234
- // Task routing — takes priority over workspace and all other commands
235
- const existingTask = this.tasks.get(msg.chatId);
236
- if (existingTask) {
237
- this.enqueueMessage(msg, existingTask, state);
238
- return;
239
- }
240
570
  const ws = this.config.workspaceByChat(msg.chatId);
241
571
  // /mode — switch permission mode (works even during active turns)
242
572
  if (msg.text === "/mode") {
243
- const currentMode = state.permissionMode ?? this.config.permissionMode;
573
+ const currentMode = state.permissionMode ?? this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
244
574
  const buttons = [
245
575
  MODE_OPTIONS.slice(0, 2).map((opt) => ({
246
576
  label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
@@ -268,9 +598,38 @@ export class Orchestrator {
268
598
  this.config.clearSession(ws.name);
269
599
  state.permissionMode = null;
270
600
  state.stats = null;
271
- await this.updateStatusMessage(msg.chatId, state);
272
601
  log.info("[cmd] session cleared for workspace %s", ws.name);
273
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
+ }
274
633
  return;
275
634
  }
276
635
  // /resume — switch to a previous session
@@ -283,21 +642,22 @@ export class Orchestrator {
283
642
  await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
284
643
  return;
285
644
  }
286
- const sessions = await this.engine.listSessions(ws.cwd);
645
+ const sessions = await this.engineFor(ws).listSessions(ws.cwd);
287
646
  if (sessions.length === 0) {
288
647
  await this.channel.sendMessage(msg.chatId, "No sessions found for this workspace.");
289
648
  return;
290
649
  }
291
650
  const MAX_BTN = 45;
292
- // Strip ClearClaw's "[User (@handle)]: " prefix from SDK summaries
651
+ // SDK summaries can contain the session's latest prompt, including
652
+ // ClearClaw's bracketed framing. Drop that framing for display.
293
653
  const stripped = sessions.map((s) => ({
294
654
  ...s,
295
- summary: s.summary.replace(/^\[.*?\]:\s*/, ""),
655
+ summary: stripPromptPrefix(s.summary),
296
656
  }));
297
657
  // Build detailed list for message body
298
658
  const listing = stripped.map((s, i) => {
299
659
  const current = s.sessionId === ws.current_session_id ? " ✅" : "";
300
- 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(" · ");
301
661
  return `${i + 1}. ${s.summary}${current}\n ${meta}`;
302
662
  }).join("\n");
303
663
  // Concise button labels (single line)
@@ -310,7 +670,9 @@ export class Orchestrator {
310
670
  });
311
671
  const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
312
672
  if (resp.value) {
313
- this.config.setSession(ws.name, resp.value);
673
+ const current = this.config.workspaceByName(ws.name);
674
+ if (current)
675
+ this.config.upsertWorkspace({ ...current, current_session_id: resp.value, engine_handoff: undefined });
314
676
  const picked = stripped.find((s) => s.sessionId === resp.value);
315
677
  await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
316
678
  log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
@@ -337,25 +699,41 @@ export class Orchestrator {
337
699
  }
338
700
  return;
339
701
  }
340
- if (!ws) {
341
- if (this.config.isAuthorized(msg.user.id)) {
342
- const newTask = {
343
- sessionId: null,
344
- cwd: path.dirname(this.config.defaultPromptPath),
345
- prompt: [
346
- "THIS IS A TASK SESSION — not a regular conversation.",
347
- "Do NOT follow the 'Every Session' startup routine. Do NOT read MEMORY.md or daily notes. Do NOT greet the user.",
348
- "Your only job: help set up a workspace for this chat. Use the onboarding skill.",
349
- ].join("\n"),
350
- };
351
- this.tasks.set(msg.chatId, newTask);
352
- log.info("[task] onboarding started for chat %s", msg.chatId);
353
- 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;
354
708
  }
355
- else {
356
- log.info("[msg] no workspace for chat %s", msg.chatId);
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) {
357
716
  await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.");
717
+ return;
358
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;
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.");
359
737
  return;
360
738
  }
361
739
  this.enqueueMessage(msg, ws, state);
@@ -376,13 +754,25 @@ export class Orchestrator {
376
754
  replyToMessageId: turnState.replyToMessageId ?? undefined,
377
755
  });
378
756
  break;
757
+ case "text_chunk": {
758
+ if (turnState.staySilent)
759
+ break;
760
+ await this.routeTextChunk(chatId, event.text, state, turnState);
761
+ break;
762
+ }
379
763
  case "tool_use": {
764
+ // Flush streaming text — final edit with markdown formatting
765
+ await this.flushTextSegment(chatId, state, turnState);
766
+ state.textHandle = null;
767
+ state.textBuffer = "";
768
+ state.textPublishedLength = 0;
380
769
  // In assistant behavior, all tool status is suppressed (no rolling status, no plan mode notifications)
381
770
  if (behavior === "assistant")
382
771
  break;
383
772
  // Relay behavior: existing display logic
384
- if (event.toolName === "TodoWrite") {
385
- const text = formatTodoList(event.input);
773
+ const { tool } = event;
774
+ if (tool.toolName === "TodoWrite") {
775
+ const text = formatTodoList(tool);
386
776
  if (state.todoHandle) {
387
777
  try {
388
778
  await this.channel.editMessage(chatId, state.todoHandle, text);
@@ -398,13 +788,13 @@ export class Orchestrator {
398
788
  }
399
789
  break;
400
790
  }
401
- if (event.toolName === "EnterPlanMode") {
791
+ if (tool.toolName === "EnterPlanMode") {
402
792
  await this.channel.sendMessage(chatId, "📋 Planning");
403
793
  break;
404
794
  }
405
- if (displayHandledTools.has(event.toolName))
795
+ if (displayHandledTools.has(tool.toolName))
406
796
  break;
407
- const line = formatToolStatusLine(event.toolName, event.input);
797
+ const line = formatToolStatusLine(tool);
408
798
  if (state.toolCallHandle) {
409
799
  try {
410
800
  await this.channel.editMessage(chatId, state.toolCallHandle, line);
@@ -422,16 +812,10 @@ export class Orchestrator {
422
812
  }
423
813
  case "tool_result":
424
814
  break;
425
- case "rate_limit": {
426
- const resetMsg = event.resetsAt
427
- ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
428
- : "";
429
- log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
430
- await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
431
- break;
432
- }
815
+ case "plan_usage":
816
+ break; // Captured account-wide in executeTurn.
433
817
  case "done":
434
- // Handled inline in executeTurn (task vs workspace need different session storage)
818
+ // Handled inline in executeTurn for session persistence.
435
819
  break;
436
820
  case "error":
437
821
  log.error(`[turn] error: ${event.message}`);
@@ -445,6 +829,66 @@ export class Orchestrator {
445
829
  log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
446
830
  }
447
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
+ }
448
892
  buildMcpTools(chatId, behavior, turnState) {
449
893
  const tools = [
450
894
  tool("send_file", "Send a file or image to the current chat conversation", {
@@ -463,33 +907,234 @@ export class Orchestrator {
463
907
  return { content: [{ type: "text", text: `Sent ${name} to chat` }] };
464
908
  }),
465
909
  ];
466
- // Task tools — only available during task turns
467
- if (this.tasks.has(chatId)) {
468
- tools.push(tool("workspace_create", "Create a new workspace and link it to the current chat", {
469
- name: z.string().describe("Workspace name (unique, e.g. 'myproject')"),
470
- cwd: z.string().describe("Absolute path to the workspace directory"),
471
- behavior: z.enum(["assistant", "relay"]).optional()
472
- .describe("Workspace behavior mode"),
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"),
473
917
  }, async (args) => {
474
- if (this.config.workspaceByName(args.name)) {
475
- throw new Error(`Workspace "${args.name}" already exists. Choose a different name.`);
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." }] };
476
933
  }
477
- fs.mkdirSync(args.cwd, { recursive: true });
478
- this.config.upsertWorkspace({
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"),
944
+ }, async (args) => {
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." }] };
964
+ }
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.` }] };
982
+ }
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({
479
1018
  name: args.name,
480
- cwd: args.cwd,
481
- chat_id: chatId,
482
- current_session_id: null,
483
- behavior: args.behavior,
1019
+ description: args.description,
1020
+ main_workspace: main.name,
484
1021
  });
485
- log.info("[tool] workspace_create: %s → %s (chat %s)", args.name, args.cwd, chatId);
486
- return { content: [{ type: "text", text: `Workspace "${args.name}" created at ${args.cwd}, linked to this chat.` }] };
487
- }), tool("task_complete", "Signal that the current task is complete", {
488
- message: z.string().optional().describe("Summary of what was accomplished"),
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(),
489
1034
  }, async (args) => {
490
- this.tasks.delete(chatId);
491
- log.info("[tool] task_complete: chat %s — %s", chatId, args.message ?? "done");
492
- return { content: [{ type: "text", text: "Task completed." }] };
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"),
1062
+ }, async (args) => {
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.` }] };
493
1138
  }));
494
1139
  }
495
1140
  if (behavior === "assistant") {
@@ -512,78 +1157,85 @@ export class Orchestrator {
512
1157
  }
513
1158
  return tools;
514
1159
  }
515
- async handlePermission(req, chatId) {
1160
+ async handlePermission(tool, chatId) {
516
1161
  // Always auto-allow ClearClaw's own MCP tools
517
- if (req.toolName.startsWith("mcp__clearclaw__")) {
518
- log.info(`[perm] ${req.toolName} → auto-allow`);
1162
+ if (tool.toolName.startsWith("mcp__clearclaw__")) {
1163
+ log.info(`[perm] ${tool.toolName} → auto-allow`);
519
1164
  return { decision: "allow" };
520
1165
  }
521
- log.info(`[perm] ${req.toolName}`);
522
- // Custom tool handler
523
- const handler = permissionHandlers.get(req.toolName);
1166
+ log.info(`[perm] ${tool.toolName}`);
1167
+ // Custom tool handler (Claude Code specific tools like EnterPlanMode)
1168
+ const handler = permissionHandlers.get(tool.toolName);
524
1169
  if (handler) {
525
- const result = handler(req.input, req.description);
1170
+ const result = handler(tool);
526
1171
  if (result === null) {
527
- log.info(`[perm] ${req.toolName} → auto-allow (handler)`);
1172
+ log.info(`[perm] ${tool.toolName} → auto-allow (handler)`);
528
1173
  await this.channel.sendMessage(chatId, "📋 Entering plan mode");
529
1174
  return { decision: "allow" };
530
1175
  }
531
1176
  try {
532
1177
  const resp = await this.channel.sendInteractive(chatId, result.text, result.buttons);
533
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
1178
+ log.info(`[perm] ${tool.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
534
1179
  return result.mapResponse(resp);
535
1180
  }
536
1181
  catch (err) {
537
- log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
1182
+ log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", tool.toolName);
538
1183
  return { decision: "deny", message: "Permission prompt could not be delivered to chat — denied automatically." };
539
1184
  }
540
1185
  }
541
1186
  try {
542
- const resp = await this.channel.sendInteractive(chatId, formatPermissionPrompt(req.toolName, req.input, req.description), [
1187
+ const resp = await this.channel.sendInteractive(chatId, formatPermissionPrompt(tool), [
543
1188
  [{ label: "👍 Allow", value: "allow" }, { label: "👎 Deny", value: "deny" }],
544
1189
  [{ label: "📝 Deny + Note", value: "deny", requestText: true }],
545
1190
  ]);
546
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
1191
+ log.info(`[perm] ${tool.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
547
1192
  return { decision: resp.value === "allow" ? "allow" : "deny", message: resp.text };
548
1193
  }
549
1194
  catch (err) {
550
- log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
1195
+ log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", tool.toolName);
551
1196
  return { decision: "deny", message: "Permission prompt could not be delivered to chat — denied automatically." };
552
1197
  }
553
1198
  }
554
1199
  async updateStatusMessage(chatId, state) {
555
- const mode = state.permissionMode ?? this.config.permissionMode;
1200
+ const ws = this.config.workspaceByChat(chatId);
1201
+ const baseMode = this.baseMode(ws ? this.effectiveBehavior(ws) : "relay");
1202
+ const mode = state.permissionMode ?? baseMode;
556
1203
  const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
557
1204
  let text;
558
1205
  if (state.stats) {
559
- const pct = state.stats.contextWindow > 0
560
- ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
561
- : 0;
562
- text = `🤖 ${formatModelName(state.stats.model)} ${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}`;
563
1211
  }
564
1212
  else {
565
- text = `🔒 ${modeLabel}`;
1213
+ text = "";
566
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}`;
567
1228
  if (text === state.lastStatusText)
568
1229
  return;
569
- await this.channel.updateStatus(chatId, text);
570
- state.lastStatusText = text;
571
- }
572
- /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
573
- readDefaultPrompt() {
574
1230
  try {
575
- const content = fs.readFileSync(this.config.defaultPromptPath, "utf-8").trim();
576
- return content || undefined;
1231
+ await this.channel.updateStatus(chatId, text);
1232
+ state.lastStatusText = text;
577
1233
  }
578
- catch {
579
- return undefined;
1234
+ catch (err) {
1235
+ log.warn({ err }, "[status] failed to update status for chat %s", chatId);
580
1236
  }
581
1237
  }
582
1238
  }
583
- /** Strip "claude-" prefix and date suffix from model ID. e.g. "claude-opus-4-6-20250514" → "opus-4-6" */
584
- function formatModelName(modelId) {
585
- return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
586
- }
587
1239
  /** Format reply context as a bracketed prefix line for the LLM prompt. */
588
1240
  function formatReplyLine(replyTo) {
589
1241
  if (!replyTo)
@@ -602,15 +1254,76 @@ function formatReplyLine(replyTo) {
602
1254
  * Build the turn prompt: `[msg:N] sender: text`, one line per message.
603
1255
  * Works for both single-message (relay) and batched (assistant) turns.
604
1256
  */
605
- function buildPrompt(messages) {
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();
606
1314
  return messages.map((msg) => {
607
- const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
1315
+ const sender = senderLabel(msg.origin);
608
1316
  const msgIdPrefix = msg.messageId ? `[msg:${msg.messageId}] ` : "";
609
1317
  const replyLine = formatReplyLine(msg.replyTo);
610
- const attachmentNote = msg.attachments?.length
611
- ? ` [${msg.attachments.length} attachment(s)]`
612
- : "";
613
- return `${replyLine}${msgIdPrefix}${sender}: ${msg.text}${attachmentNote}`;
1318
+ return `${replyLine}[${ts}] ${msgIdPrefix}${sender}: ${msg.text}`;
614
1319
  }).join("\n");
615
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
+ }
616
1329
  //# sourceMappingURL=orchestrator.js.map