openbot 0.4.0 → 0.4.2

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 (50) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/config.js +10 -0
  3. package/dist/app/server.js +200 -3
  4. package/dist/harness/index.js +18 -0
  5. package/dist/plugins/approval/index.js +35 -20
  6. package/dist/plugins/bash/index.js +195 -0
  7. package/dist/plugins/delegation/index.js +4 -2
  8. package/dist/plugins/openbot/context.js +54 -9
  9. package/dist/plugins/openbot/history.js +47 -1
  10. package/dist/plugins/openbot/index.js +43 -3
  11. package/dist/plugins/openbot/runtime.js +91 -27
  12. package/dist/plugins/openbot/system-prompt.js +21 -1
  13. package/dist/plugins/plugin-manager/index.js +87 -3
  14. package/dist/plugins/shell/index.js +2 -1
  15. package/dist/plugins/storage/files.js +67 -0
  16. package/dist/plugins/storage/index.js +184 -7
  17. package/dist/plugins/storage/service.js +201 -44
  18. package/dist/plugins/ui/index.js +109 -150
  19. package/dist/services/abort.js +43 -0
  20. package/dist/services/plugins/registry.js +5 -3
  21. package/dist/services/plugins/service.js +66 -11
  22. package/docs/agents.md +5 -8
  23. package/docs/architecture.md +1 -1
  24. package/docs/plugins.md +28 -7
  25. package/docs/templates/AGENT.example.md +4 -4
  26. package/package.json +1 -1
  27. package/src/app/cli.ts +1 -1
  28. package/src/app/config.ts +13 -0
  29. package/src/app/server.ts +235 -3
  30. package/src/app/types.ts +284 -14
  31. package/src/harness/index.ts +21 -0
  32. package/src/plugins/approval/index.ts +37 -20
  33. package/src/plugins/bash/index.ts +232 -0
  34. package/src/plugins/delegation/index.ts +5 -2
  35. package/src/plugins/openbot/context.ts +58 -9
  36. package/src/plugins/openbot/history.ts +52 -1
  37. package/src/plugins/openbot/index.ts +45 -3
  38. package/src/plugins/openbot/runtime.ts +121 -27
  39. package/src/plugins/openbot/system-prompt.ts +21 -1
  40. package/src/plugins/plugin-manager/index.ts +105 -3
  41. package/src/plugins/storage/files.ts +81 -0
  42. package/src/plugins/storage/index.ts +198 -8
  43. package/src/plugins/storage/service.ts +267 -44
  44. package/src/plugins/ui/index.ts +123 -0
  45. package/src/services/abort.ts +46 -0
  46. package/src/services/plugins/domain.ts +34 -1
  47. package/src/services/plugins/registry.ts +5 -3
  48. package/src/services/plugins/service.ts +136 -45
  49. package/src/services/plugins/types.ts +5 -1
  50. package/src/plugins/shell/index.ts +0 -123
package/dist/app/cli.js CHANGED
@@ -16,7 +16,7 @@ function checkNodeVersion() {
16
16
  }
17
17
  }
18
18
  checkNodeVersion();
19
- program.name('openbot').description('OpenBot CLI').version('0.4.0');
19
+ program.name('openbot').description('OpenBot CLI').version('0.4.2');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  export const DEFAULT_BASE_DIR = '~/.openbot';
5
+ /** Default parent directory for per-channel working directories (user-facing workspace). */
6
+ export const DEFAULT_CHANNELS_WORKSPACE_DIR = '~/openbot';
5
7
  export const DEFAULT_PLUGINS_DIR = 'plugins';
6
8
  export const DEFAULT_AGENTS_DIR = 'agents';
7
9
  export const DEFAULT_CHANNELS_DIR = 'channels';
@@ -12,6 +14,14 @@ export const DEFAULT_MARKETPLACE_REGISTRY_URL = 'https://raw.githubusercontent.c
12
14
  export function resolvePath(p) {
13
15
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(p);
14
16
  }
17
+ /** Default absolute cwd for a channel when none is provided at creation time. */
18
+ export function getDefaultChannelCwd(channelId) {
19
+ const id = channelId.trim();
20
+ if (!id) {
21
+ throw new Error('channelId is required');
22
+ }
23
+ return resolvePath(`${DEFAULT_CHANNELS_WORKSPACE_DIR}/${id}`);
24
+ }
15
25
  export function loadConfig() {
16
26
  const configPath = path.join(os.homedir(), '.openbot', CONFIG_FILE);
17
27
  if (fs.existsSync(configPath)) {
@@ -12,7 +12,10 @@ import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
12
  import { processService } from '../services/process.js';
13
13
  import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
14
14
  import { initPlugins } from '../services/plugins/registry.js';
15
+ import { storageService } from '../plugins/storage/service.js';
16
+ import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
15
17
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
18
+ import { abortRegistry, abortKey } from '../services/abort.js';
16
19
  export async function startServer(options = {}) {
17
20
  const publishEventSchema = z
18
21
  .object({
@@ -36,6 +39,9 @@ export async function startServer(options = {}) {
36
39
  await fs.mkdir(agentsDir, { recursive: true });
37
40
  await fs.mkdir(pluginsDir, { recursive: true });
38
41
  initPlugins(pluginsDir);
42
+ // Pre-warm caches for agents and plugins to speed up first UI load
43
+ storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
44
+ storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
39
45
  const getContext = (req) => {
40
46
  const channelId = req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
41
47
  const threadId = req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
@@ -48,7 +54,7 @@ export async function startServer(options = {}) {
48
54
  req.query.responseType ||
49
55
  (req.body && req.body.responseType);
50
56
  return {
51
- channelId: (channelId || (threadId ? 'general' : 'general')), // Default to general if none
57
+ channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')), // Default to uncategorized if none
52
58
  threadId: threadId,
53
59
  agentId: agentId,
54
60
  runId: runId,
@@ -59,8 +65,15 @@ export async function startServer(options = {}) {
59
65
  const getRunKey = (runId, agentId, channelId, threadId) => `${runId}:${agentId}:${channelId}:${threadId || ''}`;
60
66
  const sendToClientKey = (clientKey, chunk) => {
61
67
  const threadClients = clients.get(clientKey);
62
- if (!threadClients)
68
+ if (!threadClients || threadClients.length === 0)
63
69
  return;
70
+ // Auto-detect "read" state: if someone is listening, they just "read" this event.
71
+ if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
72
+ const parts = clientKey.split(':');
73
+ const channelId = parts[0];
74
+ const threadId = parts[1]; // undefined if no ":"
75
+ storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => { });
76
+ }
64
77
  threadClients.forEach((client) => {
65
78
  if (!client.writableEnded) {
66
79
  client.write(`data: ${JSON.stringify(chunk)}\n\n`);
@@ -103,8 +116,31 @@ export async function startServer(options = {}) {
103
116
  data: { channels },
104
117
  };
105
118
  };
119
+ // Drop every tracked run for a channel/thread. A stop aborts the whole
120
+ // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
121
+ // events can be swallowed when the parent run loop breaks on abort, leaving
122
+ // orphaned entries that keep a channel falsely "active". Purging by
123
+ // channel/thread guarantees the snapshot self-heals after a stop.
124
+ const purgeActiveRunsForThread = (channelId, threadId) => {
125
+ const target = threadId || undefined;
126
+ for (const [key, run] of activeRuns) {
127
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
128
+ activeRuns.delete(key);
129
+ }
130
+ }
131
+ };
106
132
  app.use(cors());
107
- app.use(express.json({ limit: '20mb' }));
133
+ const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
134
+ app.use((req, res, next) => {
135
+ const isWorkspaceUpload = req.method === 'POST' &&
136
+ req.path === '/api/publish' &&
137
+ req.get('x-openbot-event-type') === 'action:storage:upload-file';
138
+ if (isWorkspaceUpload) {
139
+ express.raw({ type: () => true, limit: '100mb' })(req, res, next);
140
+ return;
141
+ }
142
+ express.json({ limit: '20mb' })(req, res, next);
143
+ });
108
144
  app.get('/api/health', (req, res) => {
109
145
  res.json({ status: 'ok', version: pkg.version });
110
146
  });
@@ -128,6 +164,18 @@ export async function startServer(options = {}) {
128
164
  clients.set(clientKey, []);
129
165
  }
130
166
  clients.get(clientKey).push(res);
167
+ // Auto-detect "read" state on connection: mark the latest event as seen.
168
+ if (channelId !== GLOBAL_CHANNEL_ID) {
169
+ storageService
170
+ .getEvents({ channelId, threadId })
171
+ .then((events) => {
172
+ const latestId = events[events.length - 1]?.id;
173
+ if (latestId) {
174
+ return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
175
+ }
176
+ })
177
+ .catch(() => { });
178
+ }
131
179
  if (channelId === GLOBAL_CHANNEL_ID) {
132
180
  const snapshot = buildActiveRunsSnapshot();
133
181
  ensureEventId(snapshot);
@@ -155,6 +203,53 @@ export async function startServer(options = {}) {
155
203
  });
156
204
  });
157
205
  app.post('/api/publish', async (req, res) => {
206
+ if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
207
+ const channelId = req.get('x-openbot-channel-id') ||
208
+ (typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
209
+ const filePath = req.get('x-openbot-file-path');
210
+ const overwrite = req.get('x-openbot-file-overwrite') === 'true';
211
+ if (!channelId?.trim()) {
212
+ res.status(400).json({ error: 'channelId is required' });
213
+ return;
214
+ }
215
+ if (!filePath?.trim()) {
216
+ res.status(400).json({ error: 'x-openbot-file-path header is required' });
217
+ return;
218
+ }
219
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
220
+ if (body.length === 0) {
221
+ res.status(400).json({ error: 'Request body is empty' });
222
+ return;
223
+ }
224
+ try {
225
+ const result = await storageService.uploadChannelFile({
226
+ channelId: channelId.trim(),
227
+ path: filePath.trim(),
228
+ body,
229
+ overwrite,
230
+ });
231
+ const url = buildWorkspaceFileUrl({
232
+ baseUrl: resolvePublicBaseUrl(),
233
+ channelId: channelId.trim(),
234
+ filePath: result.path,
235
+ });
236
+ res.json({
237
+ type: 'action:storage:upload-file:result',
238
+ data: { success: true, ...result, url },
239
+ });
240
+ }
241
+ catch (error) {
242
+ res.status(400).json({
243
+ type: 'action:storage:upload-file:result',
244
+ data: {
245
+ success: false,
246
+ path: filePath,
247
+ error: error instanceof Error ? error.message : 'Upload failed',
248
+ },
249
+ });
250
+ }
251
+ return;
252
+ }
158
253
  const parseResult = publishEventSchema.safeParse(req.body);
159
254
  if (!parseResult.success) {
160
255
  res.status(400).json({
@@ -169,6 +264,76 @@ export async function startServer(options = {}) {
169
264
  res.status(400).json({ error: 'channelId is required' });
170
265
  return;
171
266
  }
267
+ if (event.type === 'action:storage:write-file') {
268
+ const data = (event.data ?? {});
269
+ if (!data.path?.trim()) {
270
+ res.status(400).json({
271
+ type: 'action:storage:write-file:result',
272
+ data: { success: false, path: '', error: 'path is required' },
273
+ });
274
+ return;
275
+ }
276
+ if (typeof data.content !== 'string') {
277
+ res.status(400).json({
278
+ type: 'action:storage:write-file:result',
279
+ data: { success: false, path: data.path, error: 'content is required' },
280
+ });
281
+ return;
282
+ }
283
+ try {
284
+ const result = await storageService.writeChannelFile({
285
+ channelId,
286
+ path: data.path.trim(),
287
+ content: data.content,
288
+ encoding: data.encoding ?? 'utf8',
289
+ overwrite: data.overwrite ?? false,
290
+ });
291
+ const url = buildWorkspaceFileUrl({
292
+ baseUrl: resolvePublicBaseUrl(),
293
+ channelId,
294
+ filePath: result.path,
295
+ });
296
+ res.json({
297
+ type: 'action:storage:write-file:result',
298
+ data: { success: true, ...result, url },
299
+ });
300
+ }
301
+ catch (error) {
302
+ res.status(400).json({
303
+ type: 'action:storage:write-file:result',
304
+ data: {
305
+ success: false,
306
+ path: data.path,
307
+ error: error instanceof Error ? error.message : 'Write failed',
308
+ },
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ // Stop request: cancel the in-flight run (and any delegated sub-agents in the
314
+ // same thread) instead of spinning up a new agent turn.
315
+ if (event.type === 'action:agent_run_stop') {
316
+ const data = (event.data ?? {});
317
+ const targetChannelId = data.channelId || channelId;
318
+ const targetThreadId = data.threadId || threadId;
319
+ const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
320
+ purgeActiveRunsForThread(targetChannelId, targetThreadId);
321
+ const stoppedEvent = {
322
+ type: 'agent:run:stopped',
323
+ data: {
324
+ runId: data.runId || runId,
325
+ agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
326
+ channelId: targetChannelId,
327
+ threadId: targetThreadId,
328
+ reason: data.reason,
329
+ },
330
+ };
331
+ ensureEventId(stoppedEvent);
332
+ sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
333
+ sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
334
+ res.json({ success: stopped });
335
+ return;
336
+ }
172
337
  const onEvent = async (chunk, state) => {
173
338
  const targetChannelId = state?.channelId || channelId;
174
339
  const targetThreadId = state?.threadId || threadId;
@@ -184,6 +349,9 @@ export async function startServer(options = {}) {
184
349
  else if (chunk.type === 'agent:run:end') {
185
350
  activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
186
351
  }
352
+ else if (chunk.type === 'agent:run:stopped') {
353
+ purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
354
+ }
187
355
  sendToClientKey(targetClientKey, chunk);
188
356
  if (chunk.type === 'agent:run:start' ||
189
357
  chunk.type === 'agent:run:end' ||
@@ -199,6 +367,7 @@ export async function startServer(options = {}) {
199
367
  event,
200
368
  channelId,
201
369
  threadId,
370
+ publicBaseUrl: resolvePublicBaseUrl(),
202
371
  onEvent,
203
372
  });
204
373
  res.sendStatus(200);
@@ -225,6 +394,33 @@ export async function startServer(options = {}) {
225
394
  return;
226
395
  }
227
396
  const { channelId, threadId, agentId, runId } = getContext(req);
397
+ if (event.type === 'action:storage:serve-file') {
398
+ const filePath = event.data?.path;
399
+ if (!channelId?.trim()) {
400
+ res.status(400).json({ error: 'channelId is required' });
401
+ return;
402
+ }
403
+ if (!filePath?.trim()) {
404
+ res.status(400).json({ error: 'path is required' });
405
+ return;
406
+ }
407
+ try {
408
+ const { abs, size, mimeType } = await storageService.getChannelFileStat({
409
+ channelId,
410
+ path: filePath.trim(),
411
+ });
412
+ res.setHeader('Content-Type', mimeType);
413
+ res.setHeader('Content-Length', String(size));
414
+ res.setHeader('Cache-Control', 'private, max-age=3600');
415
+ openChannelFileStream(abs).pipe(res);
416
+ }
417
+ catch (error) {
418
+ res.status(404).json({
419
+ error: error instanceof Error ? error.message : 'File not found',
420
+ });
421
+ }
422
+ return;
423
+ }
228
424
  const events = [];
229
425
  const onEvent = async (chunk) => {
230
426
  events.push(chunk);
@@ -238,6 +434,7 @@ export async function startServer(options = {}) {
238
434
  channelId,
239
435
  threadId,
240
436
  persistEvents: false,
437
+ publicBaseUrl: resolvePublicBaseUrl(),
241
438
  onEvent,
242
439
  });
243
440
  res.json({ events });
@@ -3,6 +3,9 @@ import { ensureEventId } from '../app/utils.js';
3
3
  import { storageService } from '../plugins/storage/service.js';
4
4
  import { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../app/agent-ids.js';
5
5
  import { resolvePlugin } from '../services/plugins/registry.js';
6
+ import { abortRegistry, abortKey } from '../services/abort.js';
7
+ import { loadConfig } from '../app/config.js';
8
+ import { getPublicBaseUrl } from '../plugins/storage/files.js';
6
9
  export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
7
10
  async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
8
11
  ensureEventId(chunk);
@@ -30,6 +33,12 @@ async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onE
30
33
  export async function runAgent(options) {
31
34
  const { runId, agentId, event, channelId, threadId, onEvent } = options;
32
35
  const persistEvents = options.persistEvents !== false;
36
+ let publicBaseUrl = options.publicBaseUrl;
37
+ if (!publicBaseUrl) {
38
+ const config = loadConfig();
39
+ const port = Number(config.port ?? process.env.PORT ?? 4132);
40
+ publicBaseUrl = getPublicBaseUrl(port, config.publicUrl);
41
+ }
33
42
  const parentAgentId = event.meta?.parentAgentId;
34
43
  const parentToolCallId = event.meta?.parentToolCallId;
35
44
  const agentDetails = await storageService.getAgentDetails({ agentId });
@@ -40,6 +49,10 @@ export async function runAgent(options) {
40
49
  threadId,
41
50
  event,
42
51
  });
52
+ // Shared per-thread abort signal so a stop request cancels this run and any
53
+ // delegated sub-agent runs (which execute in the same channel/thread).
54
+ const runKey = abortKey(channelId, threadId);
55
+ const abortSignal = abortRegistry.acquire(runKey);
43
56
  await emitEvent({
44
57
  type: 'agent:run:start',
45
58
  data: { runId, agentId, channelId, threadId },
@@ -64,11 +77,15 @@ export async function runAgent(options) {
64
77
  config: ref.config ?? {},
65
78
  storage: storageService,
66
79
  tools,
80
+ publicBaseUrl,
81
+ abortSignal,
67
82
  }));
68
83
  }
69
84
  const runtime = builder.build();
70
85
  const generator = runtime.run(event, { runId, state });
71
86
  for await (const outputEvent of generator) {
87
+ if (abortSignal.aborted)
88
+ break;
72
89
  await emitEvent(outputEvent, state, {
73
90
  persistEvents,
74
91
  channelId,
@@ -83,6 +100,7 @@ export async function runAgent(options) {
83
100
  console.error(`[harness] Error running agent ${agentId}:`, error);
84
101
  }
85
102
  finally {
103
+ abortRegistry.release(runKey);
86
104
  await emitEvent({
87
105
  type: 'agent:run:end',
88
106
  data: { runId, agentId, channelId, threadId },
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  /**
3
3
  * `approval` — gates protected tool calls behind a UI confirmation widget.
4
4
  *
5
- * This is a simplified version that intercepts specified actions (default: shell_exec)
5
+ * This is a simplified version that intercepts specified actions (default: bash)
6
6
  * and requires user approval before they are allowed to proceed.
7
7
  */
8
8
  // In-memory tracking for pending approval IDs with TTL (shared across plugin instances)
@@ -12,21 +12,19 @@ export const approvalPlugin = {
12
12
  id: 'approval',
13
13
  name: 'Approval',
14
14
  description: 'Gate protected tool calls behind a UI confirmation widget.',
15
- factory: ({ config }) => (builder) => {
16
- // Actions that require approval. Defaults to shell_exec.
17
- const actionsToApprove = config.actions || ['action:shell_exec'];
15
+ factory: ({ config, storage }) => (builder) => {
16
+ // Actions that require approval. Defaults to bash.
17
+ const actionsToApprove = config.actions || ['action:bash'];
18
18
  for (const action of actionsToApprove) {
19
19
  builder.intercept(action, (event, context) => {
20
20
  // If already approved in this flow, let it pass to the actual handler
21
21
  if (event.meta?.approvalStatus === 'approved')
22
22
  return event;
23
23
  // Otherwise, intercept and ask for approval via a UI widget
24
- const displayData = action === 'action:shell_exec'
25
- ? `\`\`\`bash\n${event.data.command}\n\`\`\``
26
- : `\`\`\`json\n${JSON.stringify(event.data, null, 2)}\n\`\`\``;
24
+ const displayData = JSON.stringify(event?.data) || '';
27
25
  const widgetId = randomUUID();
28
26
  pendingApprovals.set(widgetId, Date.now());
29
- context.suspend({
27
+ return {
30
28
  type: 'client:ui:widget',
31
29
  data: {
32
30
  widgetId,
@@ -43,7 +41,7 @@ export const approvalPlugin = {
43
41
  ],
44
42
  },
45
43
  meta: { agentId: context.state.agentId, threadId: context.state.threadId },
46
- });
44
+ };
47
45
  });
48
46
  }
49
47
  // Handle the user's response from the UI widget
@@ -67,6 +65,7 @@ export const approvalPlugin = {
67
65
  pendingApprovals.delete(widgetId);
68
66
  const originalEvent = metadata.originalEvent;
69
67
  const approved = actionId === 'approve';
68
+ const displayData = JSON.stringify(event?.data) || '';
70
69
  // Yield a "responded" widget update to the UI
71
70
  yield {
72
71
  type: 'client:ui:widget',
@@ -74,7 +73,7 @@ export const approvalPlugin = {
74
73
  widgetId,
75
74
  kind: 'message',
76
75
  title: `Action ${approved ? 'Approved' : 'Denied'}`,
77
- body: `The request for \`${originalEvent.type}\` was ${approved ? 'approved' : 'denied'}.`,
76
+ body: displayData,
78
77
  state: approved ? 'submitted' : 'cancelled',
79
78
  display: 'collapsed',
80
79
  disabled: true,
@@ -93,16 +92,32 @@ export const approvalPlugin = {
93
92
  };
94
93
  }
95
94
  else {
96
- // Emit a failure result event for the denied action
97
- // yield {
98
- // type: `${originalEvent.type}:result` as OpenBotEvent['type'],
99
- // data: {
100
- // success: false,
101
- // error: 'Action denied by user.',
102
- // stderr: 'Action denied by user.',
103
- // },
104
- // meta: originalEvent.meta,
105
- // } as OpenBotEvent;
95
+ // Manually store the original event with denied status so it's recorded in history
96
+ // but NOT re-emitted to the pipeline (to avoid actual execution).
97
+ if (storage) {
98
+ await storage.storeEvent({
99
+ channelId: context.state.channelId,
100
+ threadId: context.state.threadId,
101
+ event: {
102
+ ...originalEvent,
103
+ meta: {
104
+ ...(originalEvent.meta || {}),
105
+ approvalStatus: 'denied',
106
+ },
107
+ },
108
+ });
109
+ }
110
+ // Emit a failure result event for the denied action to clear the pending tool batch
111
+ yield {
112
+ type: `${originalEvent.type}:result`,
113
+ data: {
114
+ success: false,
115
+ error: 'Action denied by user.',
116
+ stderr: 'Action denied by user.',
117
+ output: 'Action denied by user.',
118
+ },
119
+ meta: originalEvent.meta,
120
+ };
106
121
  yield {
107
122
  type: 'agent:output',
108
123
  data: { content: `Action \`${originalEvent.type}\` was denied.` },
@@ -0,0 +1,195 @@
1
+ import { z } from 'zod';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { resolvePath } from '../../app/config.js';
5
+ const bashToolDefinitions = {
6
+ bash: {
7
+ description: '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.',
8
+ inputSchema: z.object({
9
+ command: z.string().describe('The bash command to execute.'),
10
+ restart: z
11
+ .boolean()
12
+ .optional()
13
+ .describe('Restart the bash session before running the command.'),
14
+ }),
15
+ },
16
+ bash_stop: {
17
+ description: 'Stop the bash session for the current or specified channel.',
18
+ inputSchema: z.object({
19
+ channelId: z.string().optional().describe('The channel ID to stop the session for.'),
20
+ }),
21
+ },
22
+ bash_list_sessions: {
23
+ description: 'List all active bash sessions.',
24
+ inputSchema: z.object({}),
25
+ },
26
+ };
27
+ const sessions = new Map();
28
+ const getSession = (channelId, initialCwd) => {
29
+ let session = sessions.get(channelId);
30
+ if (!session) {
31
+ const childProcess = spawn('bash', ['--login'], {
32
+ cwd: initialCwd,
33
+ env: { ...process.env, PS1: '' },
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ });
36
+ session = {
37
+ process: childProcess,
38
+ cwd: initialCwd,
39
+ lastActivity: Date.now(),
40
+ };
41
+ sessions.set(channelId, session);
42
+ // Basic error handling for the process
43
+ childProcess.on('error', (err) => {
44
+ console.error(`[bash] Session error for channel ${channelId}:`, err);
45
+ sessions.delete(channelId);
46
+ });
47
+ childProcess.on('exit', () => {
48
+ sessions.delete(channelId);
49
+ });
50
+ }
51
+ return session;
52
+ };
53
+ const bashPluginRuntime = () => (builder) => {
54
+ builder.on('action:bash', async function* (event, context) {
55
+ const { command, restart } = event.data;
56
+ const channelId = context.state.channelId;
57
+ const initialCwd = resolvePath(context.state.channelDetails?.cwd || process.cwd());
58
+ if (restart) {
59
+ const oldSession = sessions.get(channelId);
60
+ if (oldSession) {
61
+ oldSession.process.kill();
62
+ sessions.delete(channelId);
63
+ }
64
+ }
65
+ const session = getSession(channelId, initialCwd);
66
+ session.lastActivity = Date.now();
67
+ try {
68
+ const result = await new Promise((resolve) => {
69
+ let stdout = '';
70
+ let stderr = '';
71
+ let timedOut = false;
72
+ const sentinel = `__OPENBOT_BASH_DONE_${Math.random().toString(36).substring(7)}__`;
73
+ const timeoutMs = 60000; // 1 minute timeout for tool calls
74
+ const timer = setTimeout(() => {
75
+ timedOut = true;
76
+ // We don't kill the session on timeout, just return what we have
77
+ resolve({ exitCode: null, stdout, stderr, timedOut });
78
+ }, timeoutMs);
79
+ const onStdout = (data) => {
80
+ const str = data.toString();
81
+ if (str.includes(sentinel)) {
82
+ const parts = str.split(sentinel);
83
+ stdout += parts[0];
84
+ const exitCodeMatch = parts[1].match(/EXIT:(\d+)/);
85
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
86
+ cleanup();
87
+ resolve({ exitCode, stdout, stderr, timedOut: false });
88
+ }
89
+ else {
90
+ stdout += str;
91
+ }
92
+ };
93
+ const onStderr = (data) => {
94
+ stderr += data.toString();
95
+ };
96
+ const cleanup = () => {
97
+ clearTimeout(timer);
98
+ session.process.stdout?.removeListener('data', onStdout);
99
+ session.process.stderr?.removeListener('data', onStderr);
100
+ };
101
+ session.process.stdout?.on('data', onStdout);
102
+ session.process.stderr?.on('data', onStderr);
103
+ // Execute command and then echo the sentinel with exit code
104
+ session.process.stdin?.write(`${command}\necho "${sentinel}EXIT:$?"\n`);
105
+ });
106
+ yield {
107
+ type: 'action:bash:result',
108
+ data: {
109
+ success: result.exitCode === 0 && !result.timedOut,
110
+ exitCode: result.exitCode,
111
+ stdout: result.stdout.trim(),
112
+ stderr: result.stderr.trim(),
113
+ timedOut: result.timedOut,
114
+ output: result.stderr.trim() ? result.stderr.trim() : result.stdout.trim(),
115
+ },
116
+ meta: event.meta,
117
+ };
118
+ }
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : 'Unknown bash error';
121
+ yield {
122
+ type: 'action:bash:result',
123
+ data: {
124
+ success: false,
125
+ exitCode: -1,
126
+ stdout: '',
127
+ stderr: message,
128
+ timedOut: false,
129
+ error: message,
130
+ output: message,
131
+ },
132
+ meta: event.meta,
133
+ };
134
+ }
135
+ });
136
+ // Add a tool to stop/kill the session
137
+ builder.on('action:bash_stop', async function* (event, context) {
138
+ const channelId = event.data?.channelId || context.state.channelId;
139
+ const session = sessions.get(channelId);
140
+ if (session) {
141
+ session.process.kill();
142
+ sessions.delete(channelId);
143
+ }
144
+ yield {
145
+ type: 'action:bash_stop:result',
146
+ data: { success: true, output: `Bash session for channel ${channelId} stopped.` },
147
+ meta: event.meta,
148
+ };
149
+ });
150
+ // Add a tool to list all active sessions
151
+ builder.on('action:bash_list_sessions', async function* (event, context) {
152
+ const activeSessions = Array.from(sessions.entries()).map(([channelId, session]) => ({
153
+ channelId,
154
+ cwd: session.cwd,
155
+ lastActivity: session.lastActivity,
156
+ }));
157
+ yield {
158
+ type: 'client:ui:widget',
159
+ data: {
160
+ widgetId: randomUUID(),
161
+ kind: 'list',
162
+ title: 'Active Bash Sessions',
163
+ description: `Found ${activeSessions.length} active bash session${activeSessions.length === 1 ? '' : 's'}.`,
164
+ items: activeSessions.map((s) => ({
165
+ id: s.channelId,
166
+ label: s.channelId,
167
+ description: `CWD: ${s.cwd}`,
168
+ status: 'done',
169
+ metadata: {
170
+ cwd: s.cwd,
171
+ lastActivity: s.lastActivity,
172
+ },
173
+ })),
174
+ },
175
+ meta: event.meta,
176
+ };
177
+ yield {
178
+ type: 'action:bash_list_sessions:result',
179
+ data: {
180
+ success: true,
181
+ sessions: activeSessions,
182
+ output: JSON.stringify(activeSessions),
183
+ },
184
+ meta: event.meta,
185
+ };
186
+ });
187
+ };
188
+ export const bashPlugin = {
189
+ id: 'bash',
190
+ name: 'Bash',
191
+ description: 'Stateful bash session for the channel.',
192
+ toolDefinitions: bashToolDefinitions,
193
+ factory: () => bashPluginRuntime(),
194
+ };
195
+ export default bashPlugin;