openbot 0.4.7 → 0.5.4

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.
Files changed (130) hide show
  1. package/.claude/agents/runtime-security-reviewer.md +32 -0
  2. package/.claude/hooks/format.sh +18 -0
  3. package/.claude/hooks/protect-secrets.sh +23 -0
  4. package/.claude/settings.json +26 -0
  5. package/.claude/skills/deploy/SKILL.md +26 -0
  6. package/.claude/skills/new-plugin/SKILL.md +46 -0
  7. package/.dockerignore +9 -0
  8. package/.mcp.json +8 -0
  9. package/AGENTS.md +2 -2
  10. package/CLAUDE.md +50 -0
  11. package/Dockerfile +41 -0
  12. package/deploy/README.md +61 -0
  13. package/deploy/entrypoint.sh +6 -0
  14. package/deploy/fly.toml +36 -0
  15. package/dist/app/bootstrap.js +29 -0
  16. package/dist/app/cli.js +3 -1
  17. package/dist/app/cloud-mode.js +26 -0
  18. package/dist/app/config.js +61 -16
  19. package/dist/app/server.js +38 -6
  20. package/dist/plugins/approval/index.js +1 -1
  21. package/dist/plugins/bash/index.js +386 -164
  22. package/dist/plugins/memory/service.js +2 -5
  23. package/dist/plugins/openbot/context.js +17 -1
  24. package/dist/plugins/openbot/index.js +23 -8
  25. package/dist/plugins/openbot/model.js +59 -0
  26. package/dist/plugins/openbot/runtime.js +39 -56
  27. package/dist/plugins/openbot/system-prompt.js +43 -8
  28. package/dist/plugins/preview/index.js +267 -0
  29. package/dist/plugins/storage/index.js +3 -3
  30. package/dist/plugins/storage/service.js +58 -12
  31. package/dist/plugins/todo/index.js +131 -38
  32. package/dist/plugins/todo/service.js +93 -0
  33. package/dist/plugins/ui/index.js +8 -1
  34. package/dist/services/plugins/model-registry.js +101 -0
  35. package/dist/services/plugins/registry.js +6 -4
  36. package/dist/services/plugins/service.js +2 -6
  37. package/docs/agents.md +20 -2
  38. package/docs/architecture.md +1 -1
  39. package/docs/plugins.md +13 -3
  40. package/docs/templates/AGENT.example.md +1 -1
  41. package/package.json +5 -2
  42. package/pnpm-workspace.yaml +2 -0
  43. package/src/app/bootstrap.ts +43 -0
  44. package/src/app/cli.ts +3 -1
  45. package/src/app/cloud-mode.ts +41 -0
  46. package/src/app/config.ts +71 -19
  47. package/src/app/server.ts +45 -6
  48. package/src/app/types.ts +162 -31
  49. package/src/plugins/approval/index.ts +1 -1
  50. package/src/plugins/bash/index.ts +498 -185
  51. package/src/plugins/memory/service.ts +2 -5
  52. package/src/plugins/openbot/context.ts +17 -1
  53. package/src/plugins/openbot/index.ts +25 -10
  54. package/src/plugins/openbot/model.ts +76 -0
  55. package/src/plugins/openbot/runtime.ts +47 -69
  56. package/src/plugins/openbot/system-prompt.ts +43 -8
  57. package/src/plugins/preview/index.ts +323 -0
  58. package/src/plugins/storage/index.ts +5 -8
  59. package/src/plugins/storage/service.ts +79 -15
  60. package/src/plugins/todo/index.ts +166 -0
  61. package/src/plugins/todo/service.ts +123 -0
  62. package/src/plugins/ui/index.ts +8 -1
  63. package/src/services/plugins/domain.ts +2 -0
  64. package/src/services/plugins/model-registry.ts +146 -0
  65. package/src/services/plugins/registry.ts +6 -4
  66. package/src/services/plugins/service.ts +2 -6
  67. package/dist/agents/openbot/index.js +0 -76
  68. package/dist/agents/openbot/middleware/approval.js +0 -132
  69. package/dist/agents/openbot/runtime.js +0 -289
  70. package/dist/agents/openbot/system-prompt.js +0 -32
  71. package/dist/agents/openbot/tools/delegation.js +0 -78
  72. package/dist/agents/openbot/tools/mcp.js +0 -99
  73. package/dist/agents/openbot/tools/shell.js +0 -91
  74. package/dist/agents/openbot/tools/storage.js +0 -75
  75. package/dist/agents/openbot/tools/ui.js +0 -176
  76. package/dist/agents/system.js +0 -33
  77. package/dist/bus/agent-package.js +0 -1
  78. package/dist/bus/plugin.js +0 -1
  79. package/dist/bus/services.js +0 -754
  80. package/dist/bus/types.js +0 -1
  81. package/dist/harness/agent-harness.js +0 -45
  82. package/dist/harness/agent-invoke-run.js +0 -44
  83. package/dist/harness/agent-turn.js +0 -99
  84. package/dist/harness/channel-participants.js +0 -40
  85. package/dist/harness/constants.js +0 -2
  86. package/dist/harness/context-meter.js +0 -97
  87. package/dist/harness/context.js +0 -363
  88. package/dist/harness/dispatch.js +0 -144
  89. package/dist/harness/dispatcher.js +0 -156
  90. package/dist/harness/event-normalizer.js +0 -59
  91. package/dist/harness/history.js +0 -177
  92. package/dist/harness/mcp.js +0 -61
  93. package/dist/harness/orchestration.js +0 -88
  94. package/dist/harness/orchestrator.js +0 -149
  95. package/dist/harness/participants.js +0 -22
  96. package/dist/harness/process.js +0 -29
  97. package/dist/harness/queue-processor.js +0 -187
  98. package/dist/harness/run-harness.js +0 -154
  99. package/dist/harness/run.js +0 -98
  100. package/dist/harness/runtime-factory.js +0 -73
  101. package/dist/harness/runtime.js +0 -57
  102. package/dist/harness/todo-advance.js +0 -93
  103. package/dist/harness/todo-dispatch.js +0 -51
  104. package/dist/harness/todos.js +0 -5
  105. package/dist/harness/turn.js +0 -79
  106. package/dist/harness/types.js +0 -1
  107. package/dist/plugins/ai-sdk/index.js +0 -34
  108. package/dist/plugins/ai-sdk/runtime.js +0 -395
  109. package/dist/plugins/ai-sdk/system-prompt.js +0 -18
  110. package/dist/plugins/ai-sdk.js +0 -331
  111. package/dist/plugins/approval.js +0 -163
  112. package/dist/plugins/delegation.js +0 -108
  113. package/dist/plugins/mcp/index.js +0 -108
  114. package/dist/plugins/mcp.js +0 -140
  115. package/dist/plugins/shell/index.js +0 -100
  116. package/dist/plugins/shell.js +0 -123
  117. package/dist/plugins/storage-tools/index.js +0 -76
  118. package/dist/plugins/storage.js +0 -737
  119. package/dist/plugins/thread-namer/index.js +0 -72
  120. package/dist/plugins/threads/index.js +0 -114
  121. package/dist/plugins/ui.js +0 -211
  122. package/dist/plugins/workflow/index.js +0 -65
  123. package/dist/registry/agents.js +0 -138
  124. package/dist/registry/plugins.js +0 -110
  125. package/dist/services/agent-packages.js +0 -103
  126. package/dist/services/memory.js +0 -152
  127. package/dist/services/plugins.js +0 -98
  128. package/dist/services/storage.js +0 -1028
  129. package/dist/workflow/service.js +0 -106
  130. package/dist/workflow/types.js +0 -3
@@ -1,232 +1,545 @@
1
1
  import { MelonyPlugin } from 'melony';
2
2
  import { z } from 'zod';
3
- import { spawn, ChildProcess } from 'node:child_process';
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import type { Plugin } from '../../services/plugins/types.js';
6
6
  import { OpenBotEvent, OpenBotState } from '../../app/types.js';
7
7
  import { resolvePath } from '../../app/config.js';
8
8
 
9
- const bashToolDefinitions = {
10
- bash: {
9
+ const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const FOREGROUND_DEV_TIMEOUT_MS = 5_000;
11
+ const TIMEOUT_EXIT_CODE = 124;
12
+ const MAX_LOG_CHARS = 32_000;
13
+ const DEFAULT_SESSION_ID = 'default';
14
+
15
+ const DEV_SERVER_READY_PATTERNS = [
16
+ /\bready in \d+/i,
17
+ /\blocal\s+https?:\/\//i,
18
+ /\blistening on\b/i,
19
+ /\bstarted server on\b/i,
20
+ /\bwatching for file changes\b/i,
21
+ ];
22
+
23
+ const isBackgroundedCommand = (command: string): boolean => {
24
+ const trimmed = command.trim();
25
+ return /\s&\s*$/.test(trimmed) || /\bnohup\b/.test(trimmed) || /\bdisown\b/.test(trimmed);
26
+ };
27
+
28
+ const looksLikeForegroundDevServer = (command: string): boolean => {
29
+ const trimmed = command.trim();
30
+ if (isBackgroundedCommand(trimmed)) return false;
31
+ return (
32
+ /\b(pnpm|npm|yarn|bun)\s+(run\s+)?dev\b/.test(trimmed) ||
33
+ /\b(astro|vite|next|nuxt|remix)\s+dev\b/.test(trimmed) ||
34
+ /\bpnpm\s+start\b/.test(trimmed) ||
35
+ /\bnpm\s+run\s+start\b/.test(trimmed)
36
+ );
37
+ };
38
+
39
+ const resolveExecTimeoutMs = (command: string): number =>
40
+ looksLikeForegroundDevServer(command) ? FOREGROUND_DEV_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
41
+
42
+ const isDevServerReady = (output: string): boolean =>
43
+ DEV_SERVER_READY_PATTERNS.some((pattern) => pattern.test(output));
44
+
45
+ const formatTimeoutOutput = (partialOutput: string, timeoutMs: number, ready: boolean): string => {
46
+ const seconds = Math.round(timeoutMs / 1000);
47
+ const statusLine = ready
48
+ ? `[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.]`
49
+ : `[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.]`;
50
+ const body = partialOutput.trim();
51
+ return body ? `${body}\n\n${statusLine}` : statusLine;
52
+ };
53
+
54
+ type ExecResult = {
55
+ exitCode: number;
56
+ output: string;
57
+ timedOut?: boolean;
58
+ stillRunning?: boolean;
59
+ };
60
+
61
+ const shellToolDefinitions = {
62
+ shell_exec: {
11
63
  description:
12
- 'Execute a bash command in a stateful session. The working directory and environment variables persist between calls. Use this for all system tasks, file operations, and running development servers.',
64
+ '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.',
13
65
  inputSchema: z.object({
14
- command: z.string().describe('The bash command to execute.'),
15
- restart: z
16
- .boolean()
17
- .optional()
18
- .describe('Restart the bash session before running the command.'),
66
+ id: z
67
+ .string()
68
+ .describe('Shell session identifier (e.g. "default", "server"). Reuse ids to keep state.'),
69
+ exec_dir: z
70
+ .string()
71
+ .describe('Working directory for this command (absolute path).'),
72
+ command: z.string().describe('Shell command to execute.'),
73
+ }),
74
+ },
75
+ shell_view: {
76
+ description:
77
+ 'View recent output from a shell session. Use to poll dev-server logs after shell_exec times out or after backgrounding with `&`.',
78
+ inputSchema: z.object({
79
+ id: z.string().describe('Shell session identifier.'),
80
+ }),
81
+ },
82
+ shell_wait: {
83
+ description:
84
+ '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).',
85
+ inputSchema: z.object({
86
+ id: z.string().describe('Shell session identifier.'),
87
+ seconds: z.number().int().min(1).max(300).describe('Seconds to wait.'),
19
88
  }),
20
89
  },
21
- bash_stop: {
22
- description: 'Stop the bash session for the current or specified channel.',
90
+ shell_write_to_process: {
91
+ description:
92
+ 'Write input to a running process in a shell session. Use to answer interactive prompts.',
23
93
  inputSchema: z.object({
24
- channelId: z.string().optional().describe('The channel ID to stop the session for.'),
94
+ id: z.string().describe('Shell session identifier.'),
95
+ input: z.string().describe('Input to send to the process.'),
96
+ press_enter: z
97
+ .boolean()
98
+ .describe('Whether to press Enter after the input.'),
25
99
  }),
26
100
  },
27
- bash_list_sessions: {
28
- description: 'List all active bash sessions.',
29
- inputSchema: z.object({}),
101
+ shell_kill_process: {
102
+ description:
103
+ 'Send interrupt to the active process in a shell session (e.g. stop a dev server).',
104
+ inputSchema: z.object({
105
+ id: z.string().describe('Shell session identifier.'),
106
+ }),
30
107
  },
31
108
  };
32
109
 
33
- interface BashSession {
34
- process: ChildProcess;
35
- cwd: string;
36
- lastActivity: number;
37
- }
110
+ const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
111
+
112
+ const resolveCwd = (context: { state: OpenBotState }, execDir?: string): string => {
113
+ const raw =
114
+ (typeof execDir === 'string' && execDir.trim()) ||
115
+ context.state.channelDetails?.cwd ||
116
+ process.cwd();
117
+ return resolvePath(raw);
118
+ };
119
+
120
+ const sessionKey = (channelId: string, id: string) => `${channelId}:${id}`;
121
+
122
+ class ShellSession {
123
+ private output = '';
124
+ private process: ChildProcess | null = null;
125
+ private execQueue: Promise<unknown> = Promise.resolve();
126
+ private pending:
127
+ | {
128
+ marker: string;
129
+ startLen: number;
130
+ timeoutMs: number;
131
+ resolve: (result: ExecResult) => void;
132
+ reject: (error: Error) => void;
133
+ timer: NodeJS.Timeout;
134
+ }
135
+ | undefined;
38
136
 
39
- const sessions = new Map<string, BashSession>();
137
+ constructor(
138
+ readonly channelId: string,
139
+ readonly id: string,
140
+ readonly cwd: string,
141
+ ) {
142
+ this.spawn();
143
+ }
40
144
 
41
- const getSession = (channelId: string, initialCwd: string): BashSession => {
42
- let session = sessions.get(channelId);
43
- if (!session) {
44
- const childProcess = spawn('bash', ['--login'], {
45
- cwd: initialCwd,
46
- env: { ...process.env, PS1: '' },
145
+ private spawn() {
146
+ this.process = spawn('bash', [], {
147
+ cwd: this.cwd,
148
+ env: process.env,
47
149
  stdio: ['pipe', 'pipe', 'pipe'],
48
150
  });
49
151
 
50
- session = {
51
- process: childProcess,
52
- cwd: initialCwd,
53
- lastActivity: Date.now(),
54
- };
55
- sessions.set(channelId, session);
56
-
57
- // Basic error handling for the process
58
- childProcess.on('error', (err: Error) => {
59
- console.error(`[bash] Session error for channel ${channelId}:`, err);
60
- sessions.delete(channelId);
152
+ this.process.stdout?.on('data', (chunk: Buffer) => this.append(chunk.toString()));
153
+ this.process.stderr?.on('data', (chunk: Buffer) => this.append(chunk.toString()));
154
+ this.process.on('exit', () => {
155
+ this.process = null;
156
+ this.rejectPending(new Error('Shell session exited unexpectedly'));
157
+ });
158
+ this.process.on('error', (error) => {
159
+ this.rejectPending(error);
61
160
  });
161
+ }
162
+
163
+ private rejectPending(error: Error) {
164
+ if (!this.pending) return;
165
+ clearTimeout(this.pending.timer);
166
+ this.pending.reject(error);
167
+ this.pending = undefined;
168
+ }
169
+
170
+ private append(chunk: string) {
171
+ this.output += chunk;
172
+ if (this.output.length > MAX_LOG_CHARS) {
173
+ this.output = this.output.slice(-MAX_LOG_CHARS);
174
+ }
175
+
176
+ if (!this.pending) return;
177
+ const tail = this.output.slice(this.pending.startLen);
178
+ const markerIndex = tail.indexOf(this.pending.marker);
179
+ if (markerIndex === -1) return;
180
+
181
+ const afterMarker = tail.slice(markerIndex + this.pending.marker.length);
182
+ const match = afterMarker.match(/^:(\d+)/);
183
+ const exitCode = match ? Number.parseInt(match[1], 10) : 0;
184
+ const output = tail.slice(0, markerIndex).trimEnd();
185
+
186
+ clearTimeout(this.pending.timer);
187
+ this.pending.resolve({ exitCode, output });
188
+ this.pending = undefined;
189
+ }
190
+
191
+ private ensureProcess() {
192
+ if (!this.process?.stdin) {
193
+ this.spawn();
194
+ }
195
+ if (!this.process?.stdin) {
196
+ throw new Error('Failed to start shell session');
197
+ }
198
+ return this.process;
199
+ }
200
+
201
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
202
+ const next = this.execQueue.then(fn, fn);
203
+ this.execQueue = next.catch(() => undefined);
204
+ return next;
205
+ }
206
+
207
+ view(): string {
208
+ return this.output.slice(-8000);
209
+ }
210
+
211
+ async exec(command: string, execDir: string): Promise<ExecResult> {
212
+ return this.enqueue(() => this.execInternal(command, execDir));
213
+ }
214
+
215
+ private execInternal(command: string, execDir: string): Promise<ExecResult> {
216
+ const process = this.ensureProcess();
217
+ const marker = `__OPENBOT_${randomUUID().replace(/-/g, '')}__`;
218
+ const script = `cd ${shellQuote(execDir)} && ${command}; printf '\\n${marker}:%s\\n' "$?"`;
219
+ const timeoutMs = resolveExecTimeoutMs(command);
62
220
 
63
- childProcess.on('exit', () => {
64
- sessions.delete(channelId);
221
+ return new Promise((resolve, reject) => {
222
+ if (this.pending) {
223
+ reject(new Error('Shell session is busy'));
224
+ return;
225
+ }
226
+
227
+ const startLen = this.output.length;
228
+ const timer = setTimeout(() => {
229
+ if (!this.pending) return;
230
+ const { startLen: pendingStartLen, timeoutMs: pendingTimeoutMs, resolve: pendingResolve } =
231
+ this.pending;
232
+ clearTimeout(this.pending.timer);
233
+ this.pending = undefined;
234
+
235
+ const partial = this.output.slice(pendingStartLen).trimEnd();
236
+ const ready = isDevServerReady(partial);
237
+ pendingResolve({
238
+ exitCode: TIMEOUT_EXIT_CODE,
239
+ output: formatTimeoutOutput(partial, pendingTimeoutMs, ready),
240
+ timedOut: true,
241
+ stillRunning: true,
242
+ });
243
+ }, timeoutMs);
244
+
245
+ this.pending = { marker, startLen, timeoutMs, resolve, reject, timer };
246
+
247
+ try {
248
+ process.stdin!.write(`${script}\n`);
249
+ } catch (error) {
250
+ clearTimeout(timer);
251
+ this.pending = undefined;
252
+ reject(error instanceof Error ? error : new Error(String(error)));
253
+ }
65
254
  });
66
255
  }
256
+
257
+ async wait(seconds: number): Promise<string> {
258
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
259
+ return this.view();
260
+ }
261
+
262
+ write(input: string, pressEnter: boolean) {
263
+ const process = this.ensureProcess();
264
+ process.stdin!.write(pressEnter ? `${input}\n` : input);
265
+ }
266
+
267
+ kill() {
268
+ const process = this.ensureProcess();
269
+ process.stdin!.write('\x03');
270
+ }
271
+
272
+ destroy() {
273
+ this.rejectPending(new Error('Shell session closed'));
274
+ if (!this.process) return;
275
+ try {
276
+ this.process.kill('SIGTERM');
277
+ } catch {
278
+ // ignore
279
+ }
280
+ this.process = null;
281
+ }
282
+ }
283
+
284
+ const sessions = new Map<string, ShellSession>();
285
+
286
+ const getSession = (
287
+ channelId: string,
288
+ id: string,
289
+ defaultCwd: string,
290
+ ): ShellSession => {
291
+ const key = sessionKey(channelId, id);
292
+ const existing = sessions.get(key);
293
+ if (existing) return existing;
294
+
295
+ const session = new ShellSession(channelId, id, defaultCwd);
296
+ sessions.set(key, session);
67
297
  return session;
68
298
  };
69
299
 
70
- const bashPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
71
- builder.on('action:bash', async function* (event, context) {
72
- const { command, restart } = event.data;
300
+ const destroySessionsForChannel = (channelId: string) => {
301
+ for (const [key, session] of sessions.entries()) {
302
+ if (!key.startsWith(`${channelId}:`)) continue;
303
+ session.destroy();
304
+ sessions.delete(key);
305
+ }
306
+ };
307
+
308
+ const formatResult = (output: string, extra?: Record<string, unknown>) => ({
309
+ success: true,
310
+ output: output.trim() || '(no output)',
311
+ ...extra,
312
+ });
313
+
314
+ const resolveShellWidgetId = (event: OpenBotEvent): string =>
315
+ event.meta?.toolCallId || randomUUID();
316
+
317
+ const formatShellWidgetBody = (
318
+ input: Record<string, unknown>,
319
+ output: string,
320
+ ): string => {
321
+ const inputText = JSON.stringify(input, null, 2);
322
+ const outputText = output.trim() || '(no output)';
323
+ return `Input:\n${inputText}\n\nOutput:\n${outputText}`;
324
+ };
325
+
326
+ function* emitShellWidgetPending(
327
+ event: OpenBotEvent,
328
+ context: { state: OpenBotState },
329
+ tool: string,
330
+ input: Record<string, unknown>,
331
+ widgetId: string,
332
+ ): Generator<OpenBotEvent> {
333
+ const threadId = event.meta?.threadId || context.state.threadId;
334
+
335
+ yield {
336
+ type: 'client:ui:widget',
337
+ data: {
338
+ widgetId,
339
+ kind: 'message',
340
+ title: tool,
341
+ body: formatShellWidgetBody(input, '(running...)'),
342
+ display: 'collapsed',
343
+ metadata: {
344
+ type: 'shell:tool',
345
+ tool,
346
+ input,
347
+ status: 'running',
348
+ },
349
+ },
350
+ meta: { agentId: context.state.agentId, threadId },
351
+ } as OpenBotEvent;
352
+ }
353
+
354
+ function* emitShellToolResult(
355
+ event: OpenBotEvent,
356
+ context: { state: OpenBotState },
357
+ tool: string,
358
+ input: Record<string, unknown>,
359
+ result: { success: boolean; output: string; [key: string]: unknown },
360
+ widgetId: string,
361
+ ): Generator<OpenBotEvent> {
362
+ const threadId = event.meta?.threadId || context.state.threadId;
363
+ const output = String(result.output ?? '');
364
+
365
+ yield {
366
+ type: 'client:ui:widget',
367
+ data: {
368
+ widgetId,
369
+ kind: 'message',
370
+ title: tool,
371
+ body: formatShellWidgetBody(input, output),
372
+ state: result.success ? 'submitted' : 'error',
373
+ display: 'collapsed',
374
+ metadata: {
375
+ type: 'shell:tool',
376
+ tool,
377
+ input,
378
+ output,
379
+ success: result.success,
380
+ status: result.success ? 'done' : 'error',
381
+ },
382
+ },
383
+ meta: { agentId: context.state.agentId, threadId },
384
+ } as OpenBotEvent;
385
+
386
+ const { output: _output, ...resultData } = result;
387
+ yield {
388
+ type: `action:${tool}:result` as OpenBotEvent['type'],
389
+ data: { ...resultData, output },
390
+ meta: event.meta,
391
+ } as OpenBotEvent;
392
+ }
393
+
394
+ async function* runShellTool(
395
+ event: OpenBotEvent,
396
+ context: { state: OpenBotState },
397
+ tool: string,
398
+ input: Record<string, unknown>,
399
+ execute: () => Promise<{ success: boolean; output: string; [key: string]: unknown }>,
400
+ ): AsyncGenerator<OpenBotEvent> {
401
+ const widgetId = resolveShellWidgetId(event);
402
+ yield* emitShellWidgetPending(event, context, tool, input, widgetId);
403
+
404
+ try {
405
+ const result = await execute();
406
+ yield* emitShellToolResult(event, context, tool, input, result, widgetId);
407
+ } catch (error) {
408
+ const message = error instanceof Error ? error.message : 'Unknown shell error';
409
+ yield* emitShellToolResult(
410
+ event,
411
+ context,
412
+ tool,
413
+ input,
414
+ { success: false, output: message, error: message },
415
+ widgetId,
416
+ );
417
+ }
418
+ }
419
+
420
+ const shellPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
421
+ builder.on('action:shell_exec', async function* (event, context) {
422
+ const { id, exec_dir, command } = event.data as {
423
+ id?: string;
424
+ exec_dir?: string;
425
+ command?: string;
426
+ };
427
+ const sessionId = (id || DEFAULT_SESSION_ID).trim() || DEFAULT_SESSION_ID;
73
428
  const channelId = context.state.channelId;
74
- const initialCwd = resolvePath(context.state.channelDetails?.cwd || process.cwd());
75
429
 
76
- if (restart) {
77
- const oldSession = sessions.get(channelId);
78
- if (oldSession) {
79
- oldSession.process.kill();
80
- sessions.delete(channelId);
81
- }
430
+ const input = {
431
+ id: sessionId,
432
+ exec_dir: exec_dir ?? resolveCwd(context),
433
+ command: command ?? '',
434
+ };
435
+
436
+ if (!command?.trim()) {
437
+ yield* runShellTool(event, context, 'shell_exec', input, async () => ({
438
+ success: false,
439
+ output: 'command is required',
440
+ }));
441
+ return;
82
442
  }
83
443
 
84
- const session = getSession(channelId, initialCwd);
85
- session.lastActivity = Date.now();
444
+ yield* runShellTool(event, context, 'shell_exec', input, async () => {
445
+ const execDir = resolveCwd(context, exec_dir);
446
+ const session = getSession(channelId, sessionId, execDir);
447
+ const result = await session.exec(command, execDir);
448
+ const success = result.timedOut ? isDevServerReady(result.output) : result.exitCode === 0;
449
+ return {
450
+ success,
451
+ exitCode: result.exitCode,
452
+ output: result.output.trim() || '(no output)',
453
+ ...(result.timedOut && { timedOut: true, stillRunning: result.stillRunning }),
454
+ };
455
+ });
456
+ });
86
457
 
87
- try {
88
- const result = await new Promise<{
89
- exitCode: number | null;
90
- stdout: string;
91
- stderr: string;
92
- timedOut: boolean;
93
- }>((resolve) => {
94
- let stdout = '';
95
- let stderr = '';
96
- let timedOut = false;
97
- const sentinel = `__OPENBOT_BASH_DONE_${Math.random().toString(36).substring(7)}__`;
98
-
99
- const timeoutMs = 60000; // 1 minute timeout for tool calls
100
- const timer = setTimeout(() => {
101
- timedOut = true;
102
- // We don't kill the session on timeout, just return what we have
103
- resolve({ exitCode: null, stdout, stderr, timedOut });
104
- }, timeoutMs);
105
-
106
- const onStdout = (data: Buffer) => {
107
- const str = data.toString();
108
- if (str.includes(sentinel)) {
109
- const parts = str.split(sentinel);
110
- stdout += parts[0];
111
- const exitCodeMatch = parts[1].match(/EXIT:(\d+)/);
112
- const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
113
-
114
- cleanup();
115
- resolve({ exitCode, stdout, stderr, timedOut: false });
116
- } else {
117
- stdout += str;
118
- }
119
- };
120
-
121
- const onStderr = (data: Buffer) => {
122
- stderr += data.toString();
123
- };
124
-
125
- const cleanup = () => {
126
- clearTimeout(timer);
127
- session.process.stdout?.removeListener('data', onStdout);
128
- session.process.stderr?.removeListener('data', onStderr);
129
- };
130
-
131
- session.process.stdout?.on('data', onStdout);
132
- session.process.stderr?.on('data', onStderr);
133
-
134
- // Execute command and then echo the sentinel with exit code
135
- session.process.stdin?.write(`${command}\necho "${sentinel}EXIT:$?"\n`);
136
- });
137
-
138
- yield {
139
- type: 'action:bash:result',
140
- data: {
141
- success: result.exitCode === 0 && !result.timedOut,
142
- exitCode: result.exitCode,
143
- stdout: result.stdout.trim(),
144
- stderr: result.stderr.trim(),
145
- timedOut: result.timedOut,
146
- output: result.stderr.trim() ? result.stderr.trim() : result.stdout.trim(),
147
- },
148
- meta: event.meta,
149
- } as OpenBotEvent;
150
- } catch (error) {
151
- const message = error instanceof Error ? error.message : 'Unknown bash error';
152
- yield {
153
- type: 'action:bash:result',
154
- data: {
155
- success: false,
156
- exitCode: -1,
157
- stdout: '',
158
- stderr: message,
159
- timedOut: false,
160
- error: message,
161
- output: message,
162
- },
163
- meta: event.meta,
164
- } as OpenBotEvent;
165
- }
458
+ builder.on('action:shell_view', async function* (event, context) {
459
+ const sessionId = ((event.data as { id?: string })?.id || DEFAULT_SESSION_ID).trim();
460
+ const channelId = context.state.channelId;
461
+ const defaultCwd = resolveCwd(context);
462
+ const input = { id: sessionId };
463
+
464
+ yield* runShellTool(event, context, 'shell_view', input, async () => {
465
+ const session = getSession(channelId, sessionId, defaultCwd);
466
+ return formatResult(session.view());
467
+ });
468
+ });
469
+
470
+ builder.on('action:shell_wait', async function* (event, context) {
471
+ const { id, seconds } = event.data as { id?: string; seconds?: number };
472
+ const sessionId = (id || DEFAULT_SESSION_ID).trim();
473
+ const channelId = context.state.channelId;
474
+ const defaultCwd = resolveCwd(context);
475
+ const waitedSeconds = seconds ?? 5;
476
+ const input = { id: sessionId, seconds: waitedSeconds };
477
+
478
+ yield* runShellTool(event, context, 'shell_wait', input, async () => {
479
+ const session = getSession(channelId, sessionId, defaultCwd);
480
+ const output = await session.wait(waitedSeconds);
481
+ return formatResult(output, { waitedSeconds });
482
+ });
166
483
  });
167
484
 
168
- // Add a tool to stop/kill the session
169
- builder.on('action:bash_stop', async function* (event, context) {
170
- const channelId = event.data?.channelId || context.state.channelId;
171
- const session = sessions.get(channelId);
172
- if (session) {
173
- session.process.kill();
174
- sessions.delete(channelId);
485
+ builder.on('action:shell_write_to_process', async function* (event, context) {
486
+ const { id, input: textInput, press_enter } = event.data as {
487
+ id?: string;
488
+ input?: string;
489
+ press_enter?: boolean;
490
+ };
491
+ const sessionId = (id || DEFAULT_SESSION_ID).trim();
492
+ const channelId = context.state.channelId;
493
+ const defaultCwd = resolveCwd(context);
494
+
495
+ const toolInput = {
496
+ id: sessionId,
497
+ input: textInput ?? '',
498
+ press_enter: press_enter ?? true,
499
+ };
500
+
501
+ if (!textInput?.trim()) {
502
+ yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => ({
503
+ success: false,
504
+ output: 'input is required',
505
+ }));
506
+ return;
175
507
  }
176
- yield {
177
- type: 'action:bash_stop:result',
178
- data: { success: true, output: `Bash session for channel ${channelId} stopped.` },
179
- meta: event.meta,
180
- } as OpenBotEvent;
508
+
509
+ yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => {
510
+ const session = getSession(channelId, sessionId, defaultCwd);
511
+ session.write(textInput, toolInput.press_enter);
512
+ return { success: true, output: 'Input sent to shell session.' };
513
+ });
181
514
  });
182
515
 
183
- // Add a tool to list all active sessions
184
- builder.on('action:bash_list_sessions', async function* (event, context) {
185
- const activeSessions = Array.from(sessions.entries()).map(([channelId, session]) => ({
186
- channelId,
187
- cwd: session.cwd,
188
- lastActivity: session.lastActivity,
189
- }));
190
-
191
- yield {
192
- type: 'client:ui:widget',
193
- data: {
194
- widgetId: randomUUID(),
195
- kind: 'list',
196
- title: 'Active Bash Sessions',
197
- description: `Found ${activeSessions.length} active bash session${activeSessions.length === 1 ? '' : 's'}.`,
198
- items: activeSessions.map((s) => ({
199
- id: s.channelId,
200
- label: s.channelId,
201
- description: `CWD: ${s.cwd}`,
202
- status: 'done',
203
- metadata: {
204
- cwd: s.cwd,
205
- lastActivity: s.lastActivity,
206
- },
207
- })),
208
- },
209
- meta: event.meta,
210
- } as OpenBotEvent;
211
-
212
- yield {
213
- type: 'action:bash_list_sessions:result',
214
- data: {
215
- success: true,
216
- sessions: activeSessions,
217
- output: JSON.stringify(activeSessions),
218
- },
219
- meta: event.meta,
220
- } as OpenBotEvent;
516
+ builder.on('action:shell_kill_process', async function* (event, context) {
517
+ const sessionId = ((event.data as { id?: string })?.id || DEFAULT_SESSION_ID).trim();
518
+ const channelId = context.state.channelId;
519
+ const defaultCwd = resolveCwd(context);
520
+ const input = { id: sessionId };
521
+
522
+ yield* runShellTool(event, context, 'shell_kill_process', input, async () => {
523
+ const session = getSession(channelId, sessionId, defaultCwd);
524
+ session.kill();
525
+ return { success: true, output: 'Interrupt sent to shell session.' };
526
+ });
527
+ });
528
+
529
+ builder.on('action:delete_channel', async function* (event) {
530
+ const channelId = (event.data as { channelId?: string })?.channelId;
531
+ if (channelId) {
532
+ destroySessionsForChannel(channelId);
533
+ }
221
534
  });
222
535
  };
223
536
 
224
537
  export const bashPlugin: Plugin = {
225
538
  id: 'bash',
226
- name: 'Bash',
227
- description: 'Stateful bash session for the channel.',
228
- toolDefinitions: bashToolDefinitions,
229
- factory: () => bashPluginRuntime(),
539
+ name: 'Shell',
540
+ description: 'Stateful shell sessions for the channel workspace (Manus-style).',
541
+ toolDefinitions: shellToolDefinitions,
542
+ factory: () => shellPluginRuntime(),
230
543
  };
231
544
 
232
545
  export default bashPlugin;