clearclaw 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/channel/slack.d.ts +29 -0
- package/dist/channel/slack.js +402 -0
- package/dist/channel/telegram.d.ts +15 -2
- package/dist/channel/telegram.js +223 -33
- package/dist/config.d.ts +13 -3
- package/dist/config.js +18 -4
- package/dist/engine/claude-code.d.ts +2 -1
- package/dist/engine/claude-code.js +111 -12
- package/dist/files.d.ts +3 -0
- package/dist/files.js +33 -0
- package/dist/format.d.ts +18 -9
- package/dist/format.js +86 -49
- package/dist/index.js +10 -2
- package/dist/orchestrator.d.ts +3 -0
- package/dist/orchestrator.js +205 -47
- package/dist/types.d.ts +33 -2
- package/package.json +22 -1
package/dist/format.d.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Formatting utilities for Telegram display.
|
|
3
3
|
*
|
|
4
|
-
* Uses the `diff` library for
|
|
4
|
+
* Uses the `diff` library for unified diffs in Edit permission prompts.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Summary line for the rolling tool message after a turn completes.
|
|
8
|
+
* e.g. "🔧 3× Read, 2× Grep, 1× Bash"
|
|
9
9
|
*/
|
|
10
|
-
export declare function
|
|
10
|
+
export declare function formatToolCallSummary(toolCalls: Record<string, number>): string | null;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Short one-liner for the rolling tool message. Includes key detail
|
|
13
|
+
* but truncated to stay compact.
|
|
14
14
|
*/
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function formatToolStatusLine(toolName: string, input: Record<string, unknown>): string;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Format a permission prompt for display. Returns a MarkdownV2 string.
|
|
18
|
+
* Edit/Write include the diff/preview; other tools get a one-line description.
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatPermissionPrompt(toolName: string, input: Record<string, unknown>, description: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Relative time string: "just now", "5m ago", "2h ago", "3d ago".
|
|
23
|
+
*/
|
|
24
|
+
export declare function timeAgo(epochMs: number): string;
|
|
25
|
+
/**
|
|
26
|
+
* Human-readable one-liner for a tool invocation (used in engine's canUseTool).
|
|
18
27
|
*/
|
|
19
28
|
export declare function formatToolDescription(toolName: string, input: Record<string, unknown>): string;
|
package/dist/format.js
CHANGED
|
@@ -1,22 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Formatting utilities for Telegram display.
|
|
3
3
|
*
|
|
4
|
-
* Uses the `diff` library for
|
|
4
|
+
* Uses the `diff` library for unified diffs in Edit permission prompts.
|
|
5
5
|
*/
|
|
6
6
|
import { createTwoFilesPatch } from "diff";
|
|
7
7
|
const MAX_LINES = 200;
|
|
8
|
+
const MAX_STATUS_LEN = 60;
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Summary line for the rolling tool message after a turn completes.
|
|
11
|
+
* e.g. "🔧 3× Read, 2× Grep, 1× Bash"
|
|
11
12
|
*/
|
|
12
|
-
export function
|
|
13
|
+
export function formatToolCallSummary(toolCalls) {
|
|
14
|
+
const entries = Object.entries(toolCalls);
|
|
15
|
+
if (entries.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const breakdown = entries
|
|
18
|
+
.sort(([, a], [, b]) => b - a)
|
|
19
|
+
.map(([name, count]) => `${count}× ${name}`)
|
|
20
|
+
.join(", ");
|
|
21
|
+
return `🔧 ${breakdown}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Short one-liner for the rolling tool message. Includes key detail
|
|
25
|
+
* but truncated to stay compact.
|
|
26
|
+
*/
|
|
27
|
+
export function formatToolStatusLine(toolName, input) {
|
|
28
|
+
const full = `🔧 ${formatToolStatus(toolName, input)}`;
|
|
29
|
+
if (full.length <= MAX_STATUS_LEN)
|
|
30
|
+
return full;
|
|
31
|
+
return `${full.slice(0, MAX_STATUS_LEN - 1)}…`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Format a permission prompt for display. Returns a MarkdownV2 string.
|
|
35
|
+
* Edit/Write include the diff/preview; other tools get a one-line description.
|
|
36
|
+
*/
|
|
37
|
+
export function formatPermissionPrompt(toolName, input, description) {
|
|
13
38
|
if (toolName === "Edit") {
|
|
14
|
-
|
|
39
|
+
const edit = input;
|
|
40
|
+
const patch = createTwoFilesPatch(`a/${edit.file_path}`, `b/${edit.file_path}`, edit.old_string, edit.new_string, "", "", { context: 3 });
|
|
41
|
+
const lines = patch.split("\n");
|
|
42
|
+
const start = lines.findIndex((l) => l.startsWith("---"));
|
|
43
|
+
const diffLines = start >= 0 ? lines.slice(start) : lines;
|
|
44
|
+
const body = truncateLines(diffLines, MAX_LINES);
|
|
45
|
+
return `🔐 Allow Edit? ${edit.file_path}\n\`\`\`diff\n${body}\n\`\`\``;
|
|
15
46
|
}
|
|
16
47
|
if (toolName === "Write") {
|
|
17
|
-
|
|
48
|
+
const write = input;
|
|
49
|
+
const lines = write.content.split("\n");
|
|
50
|
+
const body = truncateLines(lines, MAX_LINES);
|
|
51
|
+
return `🔐 Allow Write? ${write.file_path} (${lines.length} lines)\n\`\`\`\n${body}\n\`\`\``;
|
|
18
52
|
}
|
|
19
|
-
|
|
53
|
+
// Non-Edit/Write tools: emoji header + detail in code block
|
|
54
|
+
const detail = formatToolDetail(toolName, input);
|
|
55
|
+
const body = truncateLines(detail.split("\n"), MAX_LINES);
|
|
56
|
+
return `🔐 Allow ${toolName}?\n\`\`\`\n${body}\n\`\`\``;
|
|
20
57
|
}
|
|
21
58
|
function formatToolStatus(toolName, input) {
|
|
22
59
|
switch (toolName) {
|
|
@@ -24,6 +61,10 @@ function formatToolStatus(toolName, input) {
|
|
|
24
61
|
return `Bash: ${input.command ?? "(unknown)"}`;
|
|
25
62
|
case "Read":
|
|
26
63
|
return `Read: ${input.file_path ?? "(unknown)"}`;
|
|
64
|
+
case "Edit":
|
|
65
|
+
return `Edit: ${input.file_path ?? "(unknown)"}`;
|
|
66
|
+
case "Write":
|
|
67
|
+
return `Write: ${input.file_path ?? "(unknown)"}`;
|
|
27
68
|
case "Glob":
|
|
28
69
|
return `Glob: ${input.pattern ?? "(unknown)"}`;
|
|
29
70
|
case "Grep":
|
|
@@ -38,22 +79,43 @@ function formatToolStatus(toolName, input) {
|
|
|
38
79
|
return toolName;
|
|
39
80
|
}
|
|
40
81
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
/** Extract the key detail for a tool's permission prompt (command, query, URL, etc.). */
|
|
83
|
+
function formatToolDetail(toolName, input) {
|
|
84
|
+
switch (toolName) {
|
|
85
|
+
case "Bash":
|
|
86
|
+
return String(input.command ?? "");
|
|
87
|
+
case "Read":
|
|
88
|
+
case "Edit":
|
|
89
|
+
case "Write":
|
|
90
|
+
return String(input.file_path ?? "");
|
|
91
|
+
case "Glob":
|
|
92
|
+
case "Grep":
|
|
93
|
+
return String(input.pattern ?? "");
|
|
94
|
+
case "WebSearch":
|
|
95
|
+
return String(input.query ?? "");
|
|
96
|
+
case "WebFetch":
|
|
97
|
+
return String(input.url ?? "");
|
|
98
|
+
case "Agent":
|
|
99
|
+
return String(input.description ?? "");
|
|
100
|
+
default:
|
|
101
|
+
return JSON.stringify(input, null, 2);
|
|
102
|
+
}
|
|
50
103
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Relative time string: "just now", "5m ago", "2h ago", "3d ago".
|
|
106
|
+
*/
|
|
107
|
+
export function timeAgo(epochMs) {
|
|
108
|
+
const seconds = Math.floor((Date.now() - epochMs) / 1000);
|
|
109
|
+
if (seconds < 60)
|
|
110
|
+
return "just now";
|
|
111
|
+
const minutes = Math.floor(seconds / 60);
|
|
112
|
+
if (minutes < 60)
|
|
113
|
+
return `${minutes}m ago`;
|
|
114
|
+
const hours = Math.floor(minutes / 60);
|
|
115
|
+
if (hours < 24)
|
|
116
|
+
return `${hours}h ago`;
|
|
117
|
+
const days = Math.floor(hours / 24);
|
|
118
|
+
return `${days}d ago`;
|
|
57
119
|
}
|
|
58
120
|
function truncateLines(lines, max) {
|
|
59
121
|
if (lines.length <= max) {
|
|
@@ -63,33 +125,8 @@ function truncateLines(lines, max) {
|
|
|
63
125
|
const remaining = lines.length - max;
|
|
64
126
|
return `${shown}\n... (${remaining} more lines)`;
|
|
65
127
|
}
|
|
66
|
-
// Tools whose output is noisy or redundant (tool_use already shows input).
|
|
67
|
-
// New/unknown tools show by default.
|
|
68
|
-
const SUPPRESSED_RESULTS = new Set([
|
|
69
|
-
"Read", "Edit", "Write", "Glob", "Agent",
|
|
70
|
-
"TodoWrite", "NotebookEdit", "EnterPlanMode", "ExitPlanMode",
|
|
71
|
-
]);
|
|
72
|
-
/**
|
|
73
|
-
* Format a tool_result event for display. Returns a MarkdownV2 code block
|
|
74
|
-
* with input + output, or null if suppressed/empty.
|
|
75
|
-
*/
|
|
76
|
-
export function formatToolResult(toolName, output) {
|
|
77
|
-
if (SUPPRESSED_RESULTS.has(toolName))
|
|
78
|
-
return null;
|
|
79
|
-
if (!output.trim())
|
|
80
|
-
return null;
|
|
81
|
-
const body = truncateLines(output.split("\n"), MAX_LINES);
|
|
82
|
-
return `\`\`\`\n${body}\n\`\`\``;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Escape special characters for MarkdownV2 (outside code blocks).
|
|
86
|
-
* Inside ``` blocks, no escaping is needed.
|
|
87
|
-
*/
|
|
88
|
-
function escapeMarkdownV2(text) {
|
|
89
|
-
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
|
|
90
|
-
}
|
|
91
128
|
/**
|
|
92
|
-
* Human-readable one-liner for a tool invocation (used in
|
|
129
|
+
* Human-readable one-liner for a tool invocation (used in engine's canUseTool).
|
|
93
130
|
*/
|
|
94
131
|
export function formatToolDescription(toolName, input) {
|
|
95
132
|
switch (toolName) {
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import log, { initLogger } from "./logger.js";
|
|
|
4
4
|
import { WorkspaceStore } from "./workspace-store.js";
|
|
5
5
|
import { ClaudeCodeEngine } from "./engine/claude-code.js";
|
|
6
6
|
import { TelegramChannel } from "./channel/telegram.js";
|
|
7
|
+
import { SlackChannel } from "./channel/slack.js";
|
|
7
8
|
import { Orchestrator } from "./orchestrator.js";
|
|
8
9
|
// The Claude Agent SDK has a bug: handleControlRequest() writes to the
|
|
9
10
|
// subprocess stdin without a .catch(), so aborting during a permission
|
|
@@ -23,8 +24,14 @@ process.on("unhandledRejection", (reason) => {
|
|
|
23
24
|
async function main() {
|
|
24
25
|
const config = loadConfig();
|
|
25
26
|
initLogger(config.logPath);
|
|
26
|
-
const workspaceStore = new WorkspaceStore(config.
|
|
27
|
-
|
|
27
|
+
const workspaceStore = new WorkspaceStore(config.workspaceStorePath);
|
|
28
|
+
let channel;
|
|
29
|
+
if (config.channel.type === "slack") {
|
|
30
|
+
channel = new SlackChannel(config.channel.botToken, config.channel.appToken, config.allowedUserIds);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
channel = new TelegramChannel(config.channel.botToken, config.allowedUserIds);
|
|
34
|
+
}
|
|
28
35
|
const engine = new ClaudeCodeEngine();
|
|
29
36
|
const orchestrator = new Orchestrator({
|
|
30
37
|
channel,
|
|
@@ -32,6 +39,7 @@ async function main() {
|
|
|
32
39
|
workspaceStore,
|
|
33
40
|
permissionMode: config.permissionMode,
|
|
34
41
|
defaultPromptPath: config.defaultPromptPath,
|
|
42
|
+
filesPath: config.filesPath,
|
|
35
43
|
});
|
|
36
44
|
await orchestrator.start();
|
|
37
45
|
}
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface OrchestratorOpts {
|
|
|
6
6
|
workspaceStore: WorkspaceStore;
|
|
7
7
|
permissionMode: PermissionMode;
|
|
8
8
|
defaultPromptPath: string;
|
|
9
|
+
filesPath: string;
|
|
9
10
|
}
|
|
10
11
|
export declare class Orchestrator {
|
|
11
12
|
private channel;
|
|
@@ -13,6 +14,7 @@ export declare class Orchestrator {
|
|
|
13
14
|
private workspaceStore;
|
|
14
15
|
private permissionMode;
|
|
15
16
|
private defaultPromptPath;
|
|
17
|
+
private filesPath;
|
|
16
18
|
private chats;
|
|
17
19
|
constructor(opts: OrchestratorOpts);
|
|
18
20
|
private chat;
|
|
@@ -20,6 +22,7 @@ export declare class Orchestrator {
|
|
|
20
22
|
stop(): Promise<void>;
|
|
21
23
|
private handleMessage;
|
|
22
24
|
private routeEngineEvent;
|
|
25
|
+
private updateStatusMessage;
|
|
23
26
|
/** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
|
|
24
27
|
private readDefaultPrompt;
|
|
25
28
|
}
|
package/dist/orchestrator.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import log from "./logger.js";
|
|
4
|
-
import {
|
|
4
|
+
import { saveFile } from "./files.js";
|
|
5
|
+
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, timeAgo } from "./format.js";
|
|
6
|
+
const MODE_OPTIONS = [
|
|
7
|
+
{ label: "Default", value: "default" },
|
|
8
|
+
{ label: "Accept Edits", value: "acceptEdits" },
|
|
9
|
+
{ label: "Plan", value: "plan" },
|
|
10
|
+
{ label: "Bypass", value: "bypassPermissions" },
|
|
11
|
+
];
|
|
5
12
|
export class Orchestrator {
|
|
6
13
|
channel;
|
|
7
14
|
engine;
|
|
8
15
|
workspaceStore;
|
|
9
16
|
permissionMode;
|
|
10
17
|
defaultPromptPath;
|
|
18
|
+
filesPath;
|
|
11
19
|
chats = new Map();
|
|
12
20
|
constructor(opts) {
|
|
13
21
|
this.channel = opts.channel;
|
|
@@ -15,11 +23,15 @@ export class Orchestrator {
|
|
|
15
23
|
this.workspaceStore = opts.workspaceStore;
|
|
16
24
|
this.permissionMode = opts.permissionMode;
|
|
17
25
|
this.defaultPromptPath = opts.defaultPromptPath;
|
|
26
|
+
this.filesPath = opts.filesPath;
|
|
18
27
|
}
|
|
19
28
|
chat(chatId) {
|
|
20
29
|
let s = this.chats.get(chatId);
|
|
21
30
|
if (!s) {
|
|
22
|
-
s = {
|
|
31
|
+
s = {
|
|
32
|
+
busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
|
|
33
|
+
toolCallHandle: null,
|
|
34
|
+
};
|
|
23
35
|
this.chats.set(chatId, s);
|
|
24
36
|
}
|
|
25
37
|
return s;
|
|
@@ -46,6 +58,27 @@ export class Orchestrator {
|
|
|
46
58
|
try {
|
|
47
59
|
log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
|
|
48
60
|
const state = this.chat(msg.chatId);
|
|
61
|
+
// /mode — switch permission mode (works even during active turns)
|
|
62
|
+
if (msg.text === "/mode") {
|
|
63
|
+
const currentMode = state.permissionMode ?? this.permissionMode;
|
|
64
|
+
const buttons = [
|
|
65
|
+
MODE_OPTIONS.slice(0, 2).map((opt) => ({
|
|
66
|
+
label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
|
|
67
|
+
value: opt.value,
|
|
68
|
+
})),
|
|
69
|
+
MODE_OPTIONS.slice(2).map((opt) => ({
|
|
70
|
+
label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
|
|
71
|
+
value: opt.value,
|
|
72
|
+
})),
|
|
73
|
+
];
|
|
74
|
+
const resp = await this.channel.sendInteractive(msg.chatId, `Current mode: ${MODE_OPTIONS.find((o) => o.value === currentMode)?.label ?? currentMode}`, buttons);
|
|
75
|
+
if (resp.value) {
|
|
76
|
+
state.permissionMode = resp.value;
|
|
77
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
78
|
+
log.info("[cmd] mode → %s", resp.value);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
49
82
|
// /new — reset session
|
|
50
83
|
if (msg.text === "/new") {
|
|
51
84
|
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
@@ -54,10 +87,58 @@ export class Orchestrator {
|
|
|
54
87
|
return;
|
|
55
88
|
}
|
|
56
89
|
this.workspaceStore.clearSession(ws.name);
|
|
90
|
+
state.permissionMode = null;
|
|
91
|
+
state.stats = null;
|
|
92
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
57
93
|
log.info("[cmd] session cleared for workspace %s", ws.name);
|
|
58
94
|
await this.channel.sendMessage(msg.chatId, "Session cleared.");
|
|
59
95
|
return;
|
|
60
96
|
}
|
|
97
|
+
// /resume — switch to a previous session
|
|
98
|
+
if (msg.text === "/resume") {
|
|
99
|
+
if (state.busy) {
|
|
100
|
+
await this.channel.sendMessage(msg.chatId, "A turn is in progress. Wait for it to finish before switching sessions.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
104
|
+
if (!ws) {
|
|
105
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const sessions = await this.engine.listSessions(ws.cwd);
|
|
109
|
+
if (sessions.length === 0) {
|
|
110
|
+
await this.channel.sendMessage(msg.chatId, "No sessions found for this workspace.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const MAX_BTN = 45;
|
|
114
|
+
// Strip ClearClaw's "[User (@handle)]: " prefix from SDK summaries
|
|
115
|
+
const stripped = sessions.map((s) => ({
|
|
116
|
+
...s,
|
|
117
|
+
summary: s.summary.replace(/^\[.*?\]:\s*/, ""),
|
|
118
|
+
}));
|
|
119
|
+
// Build detailed list for message body
|
|
120
|
+
const listing = stripped.map((s, i) => {
|
|
121
|
+
const current = s.sessionId === ws.current_session_id ? " ✅" : "";
|
|
122
|
+
const meta = [timeAgo(s.lastModified), s.gitBranch].filter(Boolean).join(" · ");
|
|
123
|
+
return `${i + 1}. ${s.summary}${current}\n ${meta}`;
|
|
124
|
+
}).join("\n");
|
|
125
|
+
// Concise button labels (single line)
|
|
126
|
+
const buttons = stripped.map((s, i) => {
|
|
127
|
+
const label = `${i + 1}. ${s.summary}`;
|
|
128
|
+
const truncated = label.length > MAX_BTN
|
|
129
|
+
? label.slice(0, MAX_BTN - 1) + "…"
|
|
130
|
+
: label;
|
|
131
|
+
return [{ label: truncated, value: s.sessionId }];
|
|
132
|
+
});
|
|
133
|
+
const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
|
|
134
|
+
if (resp.value) {
|
|
135
|
+
this.workspaceStore.setSession(ws.name, resp.value);
|
|
136
|
+
const picked = stripped.find((s) => s.sessionId === resp.value);
|
|
137
|
+
await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
|
|
138
|
+
log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
61
142
|
// /cancel — abort the running turn
|
|
62
143
|
if (msg.text === "/cancel") {
|
|
63
144
|
if (state.abort) {
|
|
@@ -93,26 +174,52 @@ export class Orchestrator {
|
|
|
93
174
|
// In group workspaces, prepend sender identity so the engine knows who's talking
|
|
94
175
|
const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
|
|
95
176
|
const prompt = isDefaultWorkspace ? msg.text : `[${sender}]: ${msg.text}`;
|
|
177
|
+
// Save attachments to disk for the audit log (failures must not block the turn)
|
|
178
|
+
if (msg.attachments?.length) {
|
|
179
|
+
const results = await Promise.allSettled(msg.attachments.map((att) => saveFile(att, ws.name, this.filesPath)));
|
|
180
|
+
for (const r of results) {
|
|
181
|
+
if (r.status === "rejected")
|
|
182
|
+
log.warn({ err: r.reason }, "[turn] failed to save attachment");
|
|
183
|
+
}
|
|
184
|
+
const saved = results.filter((r) => r.status === "fulfilled").length;
|
|
185
|
+
if (saved > 0)
|
|
186
|
+
log.info("[turn] saved %d/%d attachment(s) for workspace %s", saved, msg.attachments.length, ws.name);
|
|
187
|
+
}
|
|
96
188
|
try {
|
|
97
189
|
for await (const event of this.engine.runTurn({
|
|
98
190
|
sessionId: ws.current_session_id,
|
|
99
191
|
cwd: ws.cwd,
|
|
100
192
|
prompt,
|
|
101
|
-
|
|
193
|
+
attachments: msg.attachments,
|
|
194
|
+
permissionMode: state.permissionMode ?? this.permissionMode,
|
|
102
195
|
appendSystemPrompt,
|
|
103
196
|
signal: abort.signal,
|
|
104
197
|
onPermissionRequest: async (req) => {
|
|
105
198
|
log.info(`[perm] ${req.toolName}`);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
try {
|
|
200
|
+
const promptText = formatPermissionPrompt(req.toolName, req.input, req.description);
|
|
201
|
+
const resp = await this.channel.sendInteractive(msg.chatId, promptText, [
|
|
202
|
+
[
|
|
203
|
+
{ label: "👍 Allow", value: "allow" },
|
|
204
|
+
{ label: "👎 Deny", value: "deny" },
|
|
205
|
+
],
|
|
206
|
+
[
|
|
207
|
+
{ label: "📝 Deny + Note", value: "deny", requestText: true },
|
|
208
|
+
],
|
|
209
|
+
]);
|
|
210
|
+
log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
|
|
211
|
+
return {
|
|
212
|
+
decision: resp.value === "allow" ? "allow" : "deny",
|
|
213
|
+
message: resp.text,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
|
|
218
|
+
return {
|
|
219
|
+
decision: "deny",
|
|
220
|
+
message: "Permission prompt could not be delivered to chat — denied automatically.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
116
223
|
},
|
|
117
224
|
})) {
|
|
118
225
|
await this.routeEngineEvent(msg.chatId, event, ws.name);
|
|
@@ -134,44 +241,91 @@ export class Orchestrator {
|
|
|
134
241
|
}
|
|
135
242
|
}
|
|
136
243
|
async routeEngineEvent(chatId, event, workspaceName) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
244
|
+
const state = this.chat(chatId);
|
|
245
|
+
try {
|
|
246
|
+
switch (event.type) {
|
|
247
|
+
case "text":
|
|
248
|
+
await this.channel.sendMessage(chatId, event.text);
|
|
249
|
+
break;
|
|
250
|
+
case "tool_use": {
|
|
251
|
+
// Rolling message: each tool_use replaces the single message's content
|
|
252
|
+
const line = formatToolStatusLine(event.toolName, event.input);
|
|
253
|
+
if (state.toolCallHandle) {
|
|
254
|
+
try {
|
|
255
|
+
await this.channel.editMessage(chatId, state.toolCallHandle, line);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
|
|
259
|
+
state.toolCallHandle = handles[0];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
|
|
264
|
+
state.toolCallHandle = handles[0];
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
154
267
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
268
|
+
case "tool_result":
|
|
269
|
+
// All tool results suppressed — Claude summarizes in its text response
|
|
270
|
+
break;
|
|
271
|
+
case "rate_limit": {
|
|
272
|
+
const resetMsg = event.resetsAt
|
|
273
|
+
? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
|
|
274
|
+
: "";
|
|
275
|
+
log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
|
|
276
|
+
await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "done": {
|
|
280
|
+
// Persist state first — channel calls below are best-effort
|
|
281
|
+
log.info(`[turn] done session=${event.sessionId}`);
|
|
282
|
+
this.workspaceStore.setSession(workspaceName, event.sessionId);
|
|
283
|
+
if (event.stats)
|
|
284
|
+
state.stats = event.stats;
|
|
285
|
+
// Best-effort: update tool summary and status message
|
|
286
|
+
if (state.toolCallHandle && event.stats) {
|
|
287
|
+
const summary = formatToolCallSummary(event.stats.toolCalls);
|
|
288
|
+
if (summary) {
|
|
289
|
+
try {
|
|
290
|
+
await this.channel.editMessage(chatId, state.toolCallHandle, summary);
|
|
291
|
+
}
|
|
292
|
+
catch { /* edit failed, leave as-is */ }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
state.toolCallHandle = null;
|
|
296
|
+
await this.updateStatusMessage(chatId, state);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "error":
|
|
300
|
+
log.error(`[turn] error: ${event.message}`);
|
|
301
|
+
await this.channel.sendMessage(chatId, `Error: ${event.message}`);
|
|
302
|
+
break;
|
|
164
303
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
log.error(`[turn] error: ${event.message}`);
|
|
171
|
-
await this.channel.sendMessage(chatId, `Error: ${event.message}`);
|
|
172
|
-
break;
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
// Channel errors must never propagate — they'd crash the engine turn loop,
|
|
307
|
+
// killing the SDK session and losing the session ID.
|
|
308
|
+
log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
|
|
173
309
|
}
|
|
174
310
|
}
|
|
311
|
+
async updateStatusMessage(chatId, state) {
|
|
312
|
+
const mode = state.permissionMode ?? this.permissionMode;
|
|
313
|
+
const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
|
|
314
|
+
let text;
|
|
315
|
+
if (state.stats) {
|
|
316
|
+
const pct = state.stats.contextWindow > 0
|
|
317
|
+
? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
|
|
318
|
+
: 0;
|
|
319
|
+
text = `🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
text = `🔒 ${modeLabel}`;
|
|
323
|
+
}
|
|
324
|
+
if (text === state.lastStatusText)
|
|
325
|
+
return;
|
|
326
|
+
await this.channel.updateStatus(chatId, text);
|
|
327
|
+
state.lastStatusText = text;
|
|
328
|
+
}
|
|
175
329
|
/** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
|
|
176
330
|
readDefaultPrompt() {
|
|
177
331
|
try {
|
|
@@ -183,4 +337,8 @@ export class Orchestrator {
|
|
|
183
337
|
}
|
|
184
338
|
}
|
|
185
339
|
}
|
|
340
|
+
/** Strip "claude-" prefix and date suffix from model ID. e.g. "claude-opus-4-6-20250514" → "opus-4-6" */
|
|
341
|
+
function formatModelName(modelId) {
|
|
342
|
+
return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
|
|
343
|
+
}
|
|
186
344
|
//# sourceMappingURL=orchestrator.js.map
|