@meetopenbot/openbot 0.2.7 → 1.0.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/dist/auto-model.js +13 -0
- package/dist/build-tools.js +26 -0
- package/dist/index.js +66 -67
- package/dist/output-buffer.js +31 -0
- package/dist/runtime.js +359 -483
- package/dist/stream-error.js +6 -0
- package/dist/system-prompt.js +4 -0
- package/dist/tools/approval.js +137 -108
- package/dist/tools/ask-agent.js +39 -47
- package/dist/tools/memory.js +41 -73
- package/dist/tools/start-work.js +61 -141
- package/dist/tools/storage.js +62 -424
- package/dist/tools/thread-status.js +22 -38
- package/dist/tools/thread-title.js +57 -0
- package/dist/tools/todo.js +33 -52
- package/dist/types.js +0 -8
- package/package.json +2 -2
- package/dist/tools/bash.js +0 -432
- package/dist/tools/preview.js +0 -269
- package/dist/tools/ui.js +0 -120
- package/dist/utils/workspace-url.js +0 -6
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
function formatTitleOutput(title) {
|
|
3
|
+
return `Job title set to ${title}.`;
|
|
4
|
+
}
|
|
5
|
+
export const toolDefinitions = {
|
|
6
|
+
set_thread_title: {
|
|
7
|
+
description: "Set this job's title. Use a concise, human-readable name (e.g. \"Project Brainstorming\", not \"project-brainstorm\"). Call once when the thread is unnamed; call again only if the topic clearly changes.",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
title: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.describe("Concise thread title shown in the job list."),
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export const tools = {
|
|
17
|
+
set_thread_title: {
|
|
18
|
+
...toolDefinitions.set_thread_title,
|
|
19
|
+
execute: async (rawArgs, ctx) => {
|
|
20
|
+
try {
|
|
21
|
+
const channelId = ctx.channelId ?? ctx.state.channelId ?? "";
|
|
22
|
+
const threadId = ctx.threadId ?? ctx.state.threadId;
|
|
23
|
+
if (!channelId || !threadId) {
|
|
24
|
+
throw new Error("Missing channelId or threadId for set_thread_title");
|
|
25
|
+
}
|
|
26
|
+
const data = (rawArgs ?? {});
|
|
27
|
+
const title = typeof data.title === "string" ? data.title.trim() : "";
|
|
28
|
+
if (!title) {
|
|
29
|
+
throw new Error("title is required");
|
|
30
|
+
}
|
|
31
|
+
await ctx.storage.patchThreadState({
|
|
32
|
+
channelId,
|
|
33
|
+
threadId,
|
|
34
|
+
state: {
|
|
35
|
+
name: title,
|
|
36
|
+
isSmartNamed: true,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
ctx.state.threadDetails = await ctx.storage.getThreadDetails({
|
|
40
|
+
channelId,
|
|
41
|
+
threadId,
|
|
42
|
+
});
|
|
43
|
+
const output = formatTitleOutput(title);
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
title,
|
|
47
|
+
output,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
52
|
+
return { success: false, error: message, output: message };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export const threadTitleTools = tools;
|
package/dist/tools/todo.js
CHANGED
|
@@ -46,7 +46,7 @@ function todoListWidget(args) {
|
|
|
46
46
|
display: 'collapsed',
|
|
47
47
|
state: 'open',
|
|
48
48
|
},
|
|
49
|
-
meta:
|
|
49
|
+
meta: args.meta,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
const todoItemSchema = z.object({
|
|
@@ -56,7 +56,7 @@ const todoItemSchema = z.object({
|
|
|
56
56
|
.enum(['pending', 'in_progress', 'completed', 'cancelled'])
|
|
57
57
|
.describe('Todo status. At most one item may be in_progress.'),
|
|
58
58
|
});
|
|
59
|
-
const
|
|
59
|
+
export const toolDefinitions = {
|
|
60
60
|
todo_write: {
|
|
61
61
|
description: 'Replace the current thread todo list with the provided items. Use for multi-step tasks: write the plan first, keep exactly one item in_progress while working, mark items completed immediately after finishing, and when done leave all items as completed (do not clear the list). Pass the full intended list each call (not a partial patch).',
|
|
62
62
|
inputSchema: z.object({
|
|
@@ -71,76 +71,57 @@ const todoToolDefinitions = {
|
|
|
71
71
|
inputSchema: z.object({}),
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
|
-
export const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
toolDefinitions: todoToolDefinitions,
|
|
79
|
-
factory: () => (builder) => {
|
|
80
|
-
builder.on('action:todo_write', async function* (event, context) {
|
|
81
|
-
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
74
|
+
export const tools = {
|
|
75
|
+
todo_write: {
|
|
76
|
+
...toolDefinitions.todo_write,
|
|
77
|
+
execute: async (rawArgs, ctx) => {
|
|
82
78
|
try {
|
|
83
|
-
const channelId =
|
|
84
|
-
const threadId =
|
|
79
|
+
const channelId = ctx.channelId ?? ctx.state.channelId ?? '';
|
|
80
|
+
const threadId = ctx.threadId ?? ctx.state.threadId;
|
|
85
81
|
if (!channelId || !threadId) {
|
|
86
82
|
throw new Error('Missing channelId or threadId for todo_write');
|
|
87
83
|
}
|
|
88
|
-
const items =
|
|
84
|
+
const items = (rawArgs ?? {}).items;
|
|
89
85
|
const list = await todoService.writeTodos({ channelId, threadId, items });
|
|
90
|
-
const runId =
|
|
86
|
+
const runId = ctx.runId ?? ctx.state.runId;
|
|
91
87
|
if (!runId) {
|
|
92
88
|
throw new Error('Missing runId for todo_write widget');
|
|
93
89
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
90
|
+
await ctx.emit(todoListWidget({
|
|
91
|
+
runId,
|
|
92
|
+
list,
|
|
93
|
+
meta: {
|
|
94
|
+
agentId: ctx.agentId,
|
|
95
|
+
threadId,
|
|
96
|
+
runId,
|
|
97
|
+
toolCallId: ctx.toolCallId,
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
return { success: true, list, output: formatTodoOutput(list) };
|
|
102
101
|
}
|
|
103
102
|
catch (error) {
|
|
104
103
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
105
|
-
|
|
106
|
-
type: 'action:todo_write:result',
|
|
107
|
-
data: {
|
|
108
|
-
success: false,
|
|
109
|
-
error: message,
|
|
110
|
-
output: message,
|
|
111
|
-
},
|
|
112
|
-
meta: resultMeta,
|
|
113
|
-
};
|
|
104
|
+
return { success: false, error: message, output: message };
|
|
114
105
|
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
todo_read: {
|
|
109
|
+
...toolDefinitions.todo_read,
|
|
110
|
+
execute: async (_rawArgs, ctx) => {
|
|
118
111
|
try {
|
|
119
|
-
const channelId =
|
|
120
|
-
const threadId =
|
|
112
|
+
const channelId = ctx.channelId ?? ctx.state.channelId ?? '';
|
|
113
|
+
const threadId = ctx.threadId ?? ctx.state.threadId;
|
|
121
114
|
if (!channelId || !threadId) {
|
|
122
115
|
throw new Error('Missing channelId or threadId for todo_read');
|
|
123
116
|
}
|
|
124
117
|
const list = await todoService.getTodos({ channelId, threadId });
|
|
125
|
-
|
|
126
|
-
type: 'action:todo_read:result',
|
|
127
|
-
data: { success: true, list, output: formatTodoOutput(list) },
|
|
128
|
-
meta: resultMeta,
|
|
129
|
-
};
|
|
118
|
+
return { success: true, list, output: formatTodoOutput(list) };
|
|
130
119
|
}
|
|
131
120
|
catch (error) {
|
|
132
121
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
133
|
-
|
|
134
|
-
type: 'action:todo_read:result',
|
|
135
|
-
data: {
|
|
136
|
-
success: false,
|
|
137
|
-
error: message,
|
|
138
|
-
output: message,
|
|
139
|
-
},
|
|
140
|
-
meta: resultMeta,
|
|
141
|
-
};
|
|
122
|
+
return { success: false, error: message, output: message };
|
|
142
123
|
}
|
|
143
|
-
}
|
|
124
|
+
},
|
|
144
125
|
},
|
|
145
126
|
};
|
|
146
|
-
export
|
|
127
|
+
export const todoTools = tools;
|
package/dist/types.js
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
1
|
export { definePlugin } from "@meetopenbot/plugin-sdk";
|
|
2
|
-
export function asActionBuilder(builder) {
|
|
3
|
-
return {
|
|
4
|
-
on(action, handler) {
|
|
5
|
-
builder.on(`action:${action}`, handler);
|
|
6
|
-
},
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
/** Type-safe plugin definition for the extended OpenBot host context. */
|
|
10
2
|
export function defineOpenbotPlugin(definition) {
|
|
11
3
|
return definition;
|
|
12
4
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meetopenbot/openbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "OpenBot coordinator runtime: ask specialists, route work into Spaces, and track todos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@ai-sdk/openai": "^4.0.46",
|
|
25
25
|
"ai": "^7.0.77",
|
|
26
26
|
"zod": "^4.3.5",
|
|
27
|
-
"@meetopenbot/plugin-sdk": "^0.
|
|
27
|
+
"@meetopenbot/plugin-sdk": "^1.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^20.10.1",
|
package/dist/tools/bash.js
DELETED
|
@@ -1,432 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { asActionBuilder } from '../types.js';
|
|
6
|
-
const DEFAULT_TIMEOUT_MS = 120000;
|
|
7
|
-
const FOREGROUND_DEV_TIMEOUT_MS = 5000;
|
|
8
|
-
const TIMEOUT_EXIT_CODE = 124;
|
|
9
|
-
const MAX_LOG_CHARS = 32000;
|
|
10
|
-
const DEFAULT_SESSION_ID = 'default';
|
|
11
|
-
const DEV_SERVER_READY_PATTERNS = [
|
|
12
|
-
/\bready in \d+/i,
|
|
13
|
-
/\blocal\s+https?:\/\//i,
|
|
14
|
-
/\blistening on\b/i,
|
|
15
|
-
/\bstarted server on\b/i,
|
|
16
|
-
/\bwatching for file changes\b/i,
|
|
17
|
-
];
|
|
18
|
-
const isBackgroundedCommand = (command) => {
|
|
19
|
-
const trimmed = command.trim();
|
|
20
|
-
return /\s&\s*$/.test(trimmed) || /\bnohup\b/.test(trimmed) || /\bdisown\b/.test(trimmed);
|
|
21
|
-
};
|
|
22
|
-
const looksLikeForegroundDevServer = (command) => {
|
|
23
|
-
const trimmed = command.trim();
|
|
24
|
-
if (isBackgroundedCommand(trimmed))
|
|
25
|
-
return false;
|
|
26
|
-
return (/\b(pnpm|npm|yarn|bun)\s+(run\s+)?dev\b/.test(trimmed) ||
|
|
27
|
-
/\b(astro|vite|next|nuxt|remix)\s+dev\b/.test(trimmed) ||
|
|
28
|
-
/\bpnpm\s+start\b/.test(trimmed) ||
|
|
29
|
-
/\bnpm\s+run\s+start\b/.test(trimmed));
|
|
30
|
-
};
|
|
31
|
-
const resolveExecTimeoutMs = (command) => looksLikeForegroundDevServer(command) ? FOREGROUND_DEV_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
|
32
|
-
const isDevServerReady = (output) => DEV_SERVER_READY_PATTERNS.some((pattern) => pattern.test(output));
|
|
33
|
-
const formatTimeoutOutput = (partialOutput, timeoutMs, ready) => {
|
|
34
|
-
const seconds = Math.round(timeoutMs / 1000);
|
|
35
|
-
const statusLine = ready
|
|
36
|
-
? `[shell_exec timed out after ${seconds}s — process is still running and appears ready. Use shell_view to confirm the URL/port. Do not start a duplicate server.]`
|
|
37
|
-
: `[shell_exec timed out after ${seconds}s — process may still be starting. Poll with shell_wait then shell_view until ready. Do not start a duplicate server.]`;
|
|
38
|
-
const body = partialOutput.trim();
|
|
39
|
-
return body ? `${body}\n\n${statusLine}` : statusLine;
|
|
40
|
-
};
|
|
41
|
-
const shellToolDefinitions = {
|
|
42
|
-
shell_exec: {
|
|
43
|
-
description: 'Execute a command in a stateful shell session. Blocks until the command exits. Foreground dev servers (e.g. `pnpm dev` without `&`) return after ~15s with output so far — poll with shell_wait/shell_view until ready. Prefer `pnpm dev &` to return immediately.',
|
|
44
|
-
inputSchema: z.object({
|
|
45
|
-
id: z
|
|
46
|
-
.string()
|
|
47
|
-
.describe('Shell session identifier (e.g. "default", "server"). Reuse ids to keep state.'),
|
|
48
|
-
exec_dir: z
|
|
49
|
-
.string()
|
|
50
|
-
.describe('Working directory for this command (absolute path).'),
|
|
51
|
-
command: z.string().describe('Shell command to execute.'),
|
|
52
|
-
}),
|
|
53
|
-
},
|
|
54
|
-
shell_view: {
|
|
55
|
-
description: 'View recent output from a shell session. Use to poll dev-server logs after shell_exec times out or after backgrounding with `&`.',
|
|
56
|
-
inputSchema: z.object({
|
|
57
|
-
id: z.string().describe('Shell session identifier.'),
|
|
58
|
-
}),
|
|
59
|
-
},
|
|
60
|
-
shell_wait: {
|
|
61
|
-
description: 'Wait N seconds, then return recent shell output. Use with shell_view to poll dev-server startup after shell_exec times out (e.g. shell_wait 3s, then shell_view, repeat until ready).',
|
|
62
|
-
inputSchema: z.object({
|
|
63
|
-
id: z.string().describe('Shell session identifier.'),
|
|
64
|
-
seconds: z.number().int().min(1).max(300).describe('Seconds to wait.'),
|
|
65
|
-
}),
|
|
66
|
-
},
|
|
67
|
-
shell_write_to_process: {
|
|
68
|
-
description: 'Write input to a running process in a shell session. Use to answer interactive prompts.',
|
|
69
|
-
inputSchema: z.object({
|
|
70
|
-
id: z.string().describe('Shell session identifier.'),
|
|
71
|
-
input: z.string().describe('Input to send to the process.'),
|
|
72
|
-
press_enter: z
|
|
73
|
-
.boolean()
|
|
74
|
-
.describe('Whether to press Enter after the input.'),
|
|
75
|
-
}),
|
|
76
|
-
},
|
|
77
|
-
shell_kill_process: {
|
|
78
|
-
description: 'Send interrupt to the active process in a shell session (e.g. stop a dev server).',
|
|
79
|
-
inputSchema: z.object({
|
|
80
|
-
id: z.string().describe('Shell session identifier.'),
|
|
81
|
-
}),
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
85
|
-
let resolvePathFn = (p) => p;
|
|
86
|
-
const isUsableExecDir = (value) => path.isAbsolute(value) || value.startsWith('~/');
|
|
87
|
-
const resolveCwd = (context, execDir) => {
|
|
88
|
-
const channelCwd = typeof context.state.channelDetails?.cwd === 'string'
|
|
89
|
-
? context.state.channelDetails.cwd.trim()
|
|
90
|
-
: '';
|
|
91
|
-
const requested = typeof execDir === 'string' ? execDir.trim() : '';
|
|
92
|
-
const raw = (requested && isUsableExecDir(requested) ? requested : '') ||
|
|
93
|
-
channelCwd ||
|
|
94
|
-
requested ||
|
|
95
|
-
process.cwd();
|
|
96
|
-
return resolvePathFn(raw);
|
|
97
|
-
};
|
|
98
|
-
const sessionKey = (channelId, id) => `${channelId}:${id}`;
|
|
99
|
-
class ShellSession {
|
|
100
|
-
constructor(channelId, id, cwd) {
|
|
101
|
-
this.channelId = channelId;
|
|
102
|
-
this.id = id;
|
|
103
|
-
this.cwd = cwd;
|
|
104
|
-
this.output = '';
|
|
105
|
-
this.process = null;
|
|
106
|
-
this.execQueue = Promise.resolve();
|
|
107
|
-
this.spawn();
|
|
108
|
-
}
|
|
109
|
-
spawn() {
|
|
110
|
-
this.process = spawn('bash', [], {
|
|
111
|
-
cwd: this.cwd,
|
|
112
|
-
env: process.env,
|
|
113
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
|
-
});
|
|
115
|
-
this.process.stdout?.on('data', (chunk) => this.append(chunk.toString()));
|
|
116
|
-
this.process.stderr?.on('data', (chunk) => this.append(chunk.toString()));
|
|
117
|
-
this.process.on('exit', () => {
|
|
118
|
-
this.process = null;
|
|
119
|
-
this.rejectPending(new Error('Shell session exited unexpectedly'));
|
|
120
|
-
});
|
|
121
|
-
this.process.on('error', (error) => {
|
|
122
|
-
this.rejectPending(error);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
rejectPending(error) {
|
|
126
|
-
if (!this.pending)
|
|
127
|
-
return;
|
|
128
|
-
clearTimeout(this.pending.timer);
|
|
129
|
-
this.pending.reject(error);
|
|
130
|
-
this.pending = undefined;
|
|
131
|
-
}
|
|
132
|
-
append(chunk) {
|
|
133
|
-
this.output += chunk;
|
|
134
|
-
if (this.output.length > MAX_LOG_CHARS) {
|
|
135
|
-
this.output = this.output.slice(-MAX_LOG_CHARS);
|
|
136
|
-
}
|
|
137
|
-
if (!this.pending)
|
|
138
|
-
return;
|
|
139
|
-
const tail = this.output.slice(this.pending.startLen);
|
|
140
|
-
const markerIndex = tail.indexOf(this.pending.marker);
|
|
141
|
-
if (markerIndex === -1)
|
|
142
|
-
return;
|
|
143
|
-
const afterMarker = tail.slice(markerIndex + this.pending.marker.length);
|
|
144
|
-
const match = afterMarker.match(/^:(\d+)/);
|
|
145
|
-
const exitCode = match ? Number.parseInt(match[1], 10) : 0;
|
|
146
|
-
const output = tail.slice(0, markerIndex).trimEnd();
|
|
147
|
-
clearTimeout(this.pending.timer);
|
|
148
|
-
this.pending.resolve({ exitCode, output });
|
|
149
|
-
this.pending = undefined;
|
|
150
|
-
}
|
|
151
|
-
ensureProcess() {
|
|
152
|
-
if (!this.process?.stdin) {
|
|
153
|
-
this.spawn();
|
|
154
|
-
}
|
|
155
|
-
if (!this.process?.stdin) {
|
|
156
|
-
throw new Error('Failed to start shell session');
|
|
157
|
-
}
|
|
158
|
-
return this.process;
|
|
159
|
-
}
|
|
160
|
-
enqueue(fn) {
|
|
161
|
-
const next = this.execQueue.then(fn, fn);
|
|
162
|
-
this.execQueue = next.catch(() => undefined);
|
|
163
|
-
return next;
|
|
164
|
-
}
|
|
165
|
-
view() {
|
|
166
|
-
return this.output.slice(-8000);
|
|
167
|
-
}
|
|
168
|
-
async exec(command, execDir) {
|
|
169
|
-
return this.enqueue(() => this.execInternal(command, execDir));
|
|
170
|
-
}
|
|
171
|
-
execInternal(command, execDir) {
|
|
172
|
-
const process = this.ensureProcess();
|
|
173
|
-
const marker = `__OPENBOT_${randomUUID().replace(/-/g, '')}__`;
|
|
174
|
-
const script = `cd ${shellQuote(execDir)} && ${command}; printf '\\n${marker}:%s\\n' "$?"`;
|
|
175
|
-
const timeoutMs = resolveExecTimeoutMs(command);
|
|
176
|
-
return new Promise((resolve, reject) => {
|
|
177
|
-
if (this.pending) {
|
|
178
|
-
reject(new Error('Shell session is busy'));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
const startLen = this.output.length;
|
|
182
|
-
const timer = setTimeout(() => {
|
|
183
|
-
if (!this.pending)
|
|
184
|
-
return;
|
|
185
|
-
const { startLen: pendingStartLen, timeoutMs: pendingTimeoutMs, resolve: pendingResolve } = this.pending;
|
|
186
|
-
clearTimeout(this.pending.timer);
|
|
187
|
-
this.pending = undefined;
|
|
188
|
-
const partial = this.output.slice(pendingStartLen).trimEnd();
|
|
189
|
-
const ready = isDevServerReady(partial);
|
|
190
|
-
pendingResolve({
|
|
191
|
-
exitCode: TIMEOUT_EXIT_CODE,
|
|
192
|
-
output: formatTimeoutOutput(partial, pendingTimeoutMs, ready),
|
|
193
|
-
timedOut: true,
|
|
194
|
-
stillRunning: true,
|
|
195
|
-
});
|
|
196
|
-
}, timeoutMs);
|
|
197
|
-
this.pending = { marker, startLen, timeoutMs, resolve, reject, timer };
|
|
198
|
-
try {
|
|
199
|
-
process.stdin.write(`${script}\n`);
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
clearTimeout(timer);
|
|
203
|
-
this.pending = undefined;
|
|
204
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
async wait(seconds) {
|
|
209
|
-
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
210
|
-
return this.view();
|
|
211
|
-
}
|
|
212
|
-
write(input, pressEnter) {
|
|
213
|
-
const process = this.ensureProcess();
|
|
214
|
-
process.stdin.write(pressEnter ? `${input}\n` : input);
|
|
215
|
-
}
|
|
216
|
-
kill() {
|
|
217
|
-
const process = this.ensureProcess();
|
|
218
|
-
process.stdin.write('\x03');
|
|
219
|
-
}
|
|
220
|
-
destroy() {
|
|
221
|
-
this.rejectPending(new Error('Shell session closed'));
|
|
222
|
-
if (!this.process)
|
|
223
|
-
return;
|
|
224
|
-
try {
|
|
225
|
-
this.process.kill('SIGTERM');
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
// ignore
|
|
229
|
-
}
|
|
230
|
-
this.process = null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
const sessions = new Map();
|
|
234
|
-
const getSession = (channelId, id, defaultCwd) => {
|
|
235
|
-
const key = sessionKey(channelId, id);
|
|
236
|
-
const existing = sessions.get(key);
|
|
237
|
-
if (existing)
|
|
238
|
-
return existing;
|
|
239
|
-
const session = new ShellSession(channelId, id, defaultCwd);
|
|
240
|
-
sessions.set(key, session);
|
|
241
|
-
return session;
|
|
242
|
-
};
|
|
243
|
-
const destroySessionsForChannel = (channelId) => {
|
|
244
|
-
for (const [key, session] of sessions.entries()) {
|
|
245
|
-
if (!key.startsWith(`${channelId}:`))
|
|
246
|
-
continue;
|
|
247
|
-
session.destroy();
|
|
248
|
-
sessions.delete(key);
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
const formatResult = (output, extra) => ({
|
|
252
|
-
success: true,
|
|
253
|
-
output: output.trim() || '(no output)',
|
|
254
|
-
...extra,
|
|
255
|
-
});
|
|
256
|
-
const resolveShellWidgetId = (event) => {
|
|
257
|
-
const toolCallId = event.meta?.toolCallId;
|
|
258
|
-
return typeof toolCallId === 'string' ? toolCallId : randomUUID();
|
|
259
|
-
};
|
|
260
|
-
const formatShellWidgetBody = (input, output) => {
|
|
261
|
-
const inputText = JSON.stringify(input, null, 2);
|
|
262
|
-
const outputText = output.trim() || '(no output)';
|
|
263
|
-
return `Input:\n${inputText}\n\nOutput:\n${outputText}`;
|
|
264
|
-
};
|
|
265
|
-
function* emitShellWidgetPending(event, context, tool, input, widgetId) {
|
|
266
|
-
const threadId = event.meta?.threadId || context.state.threadId;
|
|
267
|
-
yield {
|
|
268
|
-
type: 'client:ui:widget',
|
|
269
|
-
data: {
|
|
270
|
-
widgetId,
|
|
271
|
-
kind: 'message',
|
|
272
|
-
title: tool,
|
|
273
|
-
body: formatShellWidgetBody(input, '(running...)'),
|
|
274
|
-
display: 'collapsed',
|
|
275
|
-
metadata: {
|
|
276
|
-
type: 'shell:tool',
|
|
277
|
-
tool,
|
|
278
|
-
input,
|
|
279
|
-
status: 'running',
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
meta: { agentId: context.state.agentId, threadId },
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
function* emitShellToolResult(event, context, tool, input, result, widgetId) {
|
|
286
|
-
const threadId = event.meta?.threadId || context.state.threadId;
|
|
287
|
-
const output = String(result.output ?? '');
|
|
288
|
-
yield {
|
|
289
|
-
type: 'client:ui:widget',
|
|
290
|
-
data: {
|
|
291
|
-
widgetId,
|
|
292
|
-
kind: 'message',
|
|
293
|
-
title: tool,
|
|
294
|
-
body: formatShellWidgetBody(input, output),
|
|
295
|
-
state: result.success ? 'submitted' : 'error',
|
|
296
|
-
display: 'collapsed',
|
|
297
|
-
metadata: {
|
|
298
|
-
type: 'shell:tool',
|
|
299
|
-
tool,
|
|
300
|
-
input,
|
|
301
|
-
output,
|
|
302
|
-
success: result.success,
|
|
303
|
-
status: result.success ? 'done' : 'error',
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
meta: { agentId: context.state.agentId, threadId },
|
|
307
|
-
};
|
|
308
|
-
const { output: _output, ...resultData } = result;
|
|
309
|
-
yield {
|
|
310
|
-
type: `action:${tool}:result`,
|
|
311
|
-
data: { ...resultData, output },
|
|
312
|
-
meta: event.meta,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
async function* runShellTool(event, context, tool, input, execute) {
|
|
316
|
-
const widgetId = resolveShellWidgetId(event);
|
|
317
|
-
yield* emitShellWidgetPending(event, context, tool, input, widgetId);
|
|
318
|
-
try {
|
|
319
|
-
const result = await execute();
|
|
320
|
-
yield* emitShellToolResult(event, context, tool, input, result, widgetId);
|
|
321
|
-
}
|
|
322
|
-
catch (error) {
|
|
323
|
-
const message = error instanceof Error ? error.message : 'Unknown shell error';
|
|
324
|
-
yield* emitShellToolResult(event, context, tool, input, { success: false, output: message, error: message }, widgetId);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
const shellPluginRuntime = () => (builder) => {
|
|
328
|
-
const actions = asActionBuilder(builder);
|
|
329
|
-
actions.on('shell_exec', async function* (event, context) {
|
|
330
|
-
const { id, exec_dir, command } = event.data;
|
|
331
|
-
const sessionId = (id || DEFAULT_SESSION_ID).trim() || DEFAULT_SESSION_ID;
|
|
332
|
-
const channelId = context.state.channelId;
|
|
333
|
-
const input = {
|
|
334
|
-
id: sessionId,
|
|
335
|
-
exec_dir: exec_dir ?? resolveCwd(context),
|
|
336
|
-
command: command ?? '',
|
|
337
|
-
};
|
|
338
|
-
if (!command?.trim()) {
|
|
339
|
-
yield* runShellTool(event, context, 'shell_exec', input, async () => ({
|
|
340
|
-
success: false,
|
|
341
|
-
output: 'command is required',
|
|
342
|
-
}));
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
yield* runShellTool(event, context, 'shell_exec', input, async () => {
|
|
346
|
-
const execDir = resolveCwd(context, exec_dir);
|
|
347
|
-
const session = getSession(channelId, sessionId, execDir);
|
|
348
|
-
const result = await session.exec(command, execDir);
|
|
349
|
-
const success = result.timedOut ? isDevServerReady(result.output) : result.exitCode === 0;
|
|
350
|
-
return {
|
|
351
|
-
success,
|
|
352
|
-
exitCode: result.exitCode,
|
|
353
|
-
output: result.output.trim() || '(no output)',
|
|
354
|
-
...(result.timedOut && { timedOut: true, stillRunning: result.stillRunning }),
|
|
355
|
-
};
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
actions.on('shell_view', async function* (event, context) {
|
|
359
|
-
const sessionId = (event.data?.id || DEFAULT_SESSION_ID).trim();
|
|
360
|
-
const channelId = context.state.channelId;
|
|
361
|
-
const defaultCwd = resolveCwd(context);
|
|
362
|
-
const input = { id: sessionId };
|
|
363
|
-
yield* runShellTool(event, context, 'shell_view', input, async () => {
|
|
364
|
-
const session = getSession(channelId, sessionId, defaultCwd);
|
|
365
|
-
return formatResult(session.view());
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
actions.on('shell_wait', async function* (event, context) {
|
|
369
|
-
const { id, seconds } = event.data;
|
|
370
|
-
const sessionId = (id || DEFAULT_SESSION_ID).trim();
|
|
371
|
-
const channelId = context.state.channelId;
|
|
372
|
-
const defaultCwd = resolveCwd(context);
|
|
373
|
-
const waitedSeconds = seconds ?? 5;
|
|
374
|
-
const input = { id: sessionId, seconds: waitedSeconds };
|
|
375
|
-
yield* runShellTool(event, context, 'shell_wait', input, async () => {
|
|
376
|
-
const session = getSession(channelId, sessionId, defaultCwd);
|
|
377
|
-
const output = await session.wait(waitedSeconds);
|
|
378
|
-
return formatResult(output, { waitedSeconds });
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
actions.on('shell_write_to_process', async function* (event, context) {
|
|
382
|
-
const { id, input: textInput, press_enter } = event.data;
|
|
383
|
-
const sessionId = (id || DEFAULT_SESSION_ID).trim();
|
|
384
|
-
const channelId = context.state.channelId;
|
|
385
|
-
const defaultCwd = resolveCwd(context);
|
|
386
|
-
const toolInput = {
|
|
387
|
-
id: sessionId,
|
|
388
|
-
input: textInput ?? '',
|
|
389
|
-
press_enter: press_enter ?? true,
|
|
390
|
-
};
|
|
391
|
-
if (!textInput?.trim()) {
|
|
392
|
-
yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => ({
|
|
393
|
-
success: false,
|
|
394
|
-
output: 'input is required',
|
|
395
|
-
}));
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => {
|
|
399
|
-
const session = getSession(channelId, sessionId, defaultCwd);
|
|
400
|
-
session.write(textInput, toolInput.press_enter);
|
|
401
|
-
return { success: true, output: 'Input sent to shell session.' };
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
actions.on('shell_kill_process', async function* (event, context) {
|
|
405
|
-
const sessionId = (event.data?.id || DEFAULT_SESSION_ID).trim();
|
|
406
|
-
const channelId = context.state.channelId;
|
|
407
|
-
const defaultCwd = resolveCwd(context);
|
|
408
|
-
const input = { id: sessionId };
|
|
409
|
-
yield* runShellTool(event, context, 'shell_kill_process', input, async () => {
|
|
410
|
-
const session = getSession(channelId, sessionId, defaultCwd);
|
|
411
|
-
session.kill();
|
|
412
|
-
return { success: true, output: 'Interrupt sent to shell session.' };
|
|
413
|
-
});
|
|
414
|
-
});
|
|
415
|
-
actions.on('delete_channel', async function* (event) {
|
|
416
|
-
const channelId = event.data?.channelId;
|
|
417
|
-
if (channelId) {
|
|
418
|
-
destroySessionsForChannel(channelId);
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
};
|
|
422
|
-
export const bashPlugin = {
|
|
423
|
-
id: 'bash',
|
|
424
|
-
name: 'Shell',
|
|
425
|
-
description: 'Stateful shell sessions for the channel workspace (Manus-style).',
|
|
426
|
-
toolDefinitions: shellToolDefinitions,
|
|
427
|
-
factory: ({ host }) => {
|
|
428
|
-
resolvePathFn = host.resolvePath;
|
|
429
|
-
return shellPluginRuntime();
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
export default bashPlugin;
|