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.
@@ -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
- function isTask(ctx) {
11
- return "prompt" in ctx;
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
- tasks = new Map();
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
- /** Effective behavior: tasks→assistant, workspace→explicit setting or home→assistant / project→relay. */
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
- /** Enqueue a message and drain — immediately for relay, debounced for assistant/task. */
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.tasks.get(chatId) ?? this.config.workspaceByChat(chatId);
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 task = isTask(ctx) ? ctx : undefined;
133
- const ws = isTask(ctx) ? undefined : ctx;
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 = this.effectiveBehavior(ctx);
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 = task ? task.sessionId : ws.current_session_id;
140
- const cwd = task ? task.cwd : ws.cwd;
141
- const logPrefix = task ? "[task-turn]" : "[turn]";
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
- const appendSystemPrompt = task
146
- ? (assembledPrompt ? `${assembledPrompt}\n\n${task.prompt}` : task.prompt)
147
- : assembledPrompt;
148
- const prompt = buildPrompt(messages);
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 (const r of results) {
154
- if (r.status === "rejected")
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 = ws ? this.engineFor(ws) : this.engines.get(this.config.defaultEngine);
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 === "assistant" ? "bypassPermissions" : this.config.permissionMode),
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
- // Handle done event inline — task vs workspace need different session storage
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
- if (task) {
183
- const currentTask = this.tasks.get(chatId);
184
- if (currentTask)
185
- currentTask.sessionId = event.sessionId;
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
- else {
188
- this.config.setSession(ws.name, event.sessionId);
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
- // Final formatted edit for any pending streaming text
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
- // Task cancel already sent "Setup cancelled" from /cancel handler
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
- log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
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
- // /cancel — abort running turn or clear active task (must come before task routing)
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.config.permissionMode;
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
- // Strip ClearClaw's "[User (@handle)]: " prefix from SDK summaries
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.replace(/^\[.*?\]:\s*/, ""),
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.setSession(ws.name, resp.value);
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
- if (!ws) {
362
- if (this.config.isAuthorized(msg.user.id)) {
363
- const chatType = msg.chatType === "dm" ? "DM" : "group";
364
- const newTask = {
365
- sessionId: null,
366
- cwd: this.config.homeWorkspacePath,
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
- else {
379
- log.info("[msg] no workspace for chat %s", msg.chatId);
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
- state.textBuffer += event.text;
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
- if (state.textHandle && state.textBuffer) {
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 "rate_limit": {
485
- const resetMsg = event.resetsAt
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 (task vs workspace need different session storage)
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
- // Task tools — only available during task turns
526
- if (this.tasks.has(chatId)) {
527
- tools.push(tool("workspace_create", "Create a new workspace and link it to the current chat", {
528
- name: z.string().describe("Workspace name (unique, e.g. 'myproject')"),
529
- cwd: z.string().describe("Absolute path to the workspace directory"),
530
- behavior: z.enum(["assistant", "relay"]).optional()
531
- .describe("Workspace behavior mode"),
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
- if (this.config.workspaceByName(args.name)) {
536
- throw new Error(`Workspace "${args.name}" already exists. Choose a different name.`);
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
- if (args.engine && !this.engines.has(args.engine)) {
539
- throw new Error(`Unknown engine "${args.engine}". Available: ${[...this.engines.keys()].join(", ")}`);
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
- fs.mkdirSync(args.cwd, { recursive: true });
542
- this.config.upsertWorkspace({
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
- cwd: args.cwd,
545
- chat_id: chatId,
546
- current_session_id: null,
547
- behavior: args.behavior,
548
- engine: args.engine,
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] workspace_create: %s → %s engine=%s (chat %s)", args.name, args.cwd, args.engine ?? "default", chatId);
551
- return { content: [{ type: "text", text: `Workspace "${args.name}" created at ${args.cwd}, linked to this chat.` }] };
552
- }), tool("task_complete", "Signal that the current task is complete", {
553
- message: z.string().optional().describe("Summary of what was accomplished"),
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.tasks.delete(chatId);
556
- log.info("[tool] task_complete: chat %s — %s", chatId, args.message ?? "done");
557
- return { content: [{ type: "text", text: "Task completed." }] };
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 mode = state.permissionMode ?? this.config.permissionMode;
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 pct = state.stats.contextWindow > 0
625
- ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
626
- : 0;
627
- const displayName = state.stats.model
628
- ? formatModelName(state.stats.model)
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 = `🔒 ${modeLabel}`;
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
- await this.channel.updateStatus(chatId, text);
638
- state.lastStatusText = text;
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 buildPrompt(messages) {
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 = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
1347
+ const sender = senderLabel(msg.origin);
666
1348
  const msgIdPrefix = msg.messageId ? `[msg:${msg.messageId}] ` : "";
667
1349
  const replyLine = formatReplyLine(msg.replyTo);
668
- const attachmentNote = msg.attachments?.length
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