openbot 0.4.6 → 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 (132) 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/responding-agent.js +32 -0
  20. package/dist/app/server.js +47 -9
  21. package/dist/plugins/approval/index.js +1 -1
  22. package/dist/plugins/bash/index.js +386 -164
  23. package/dist/plugins/memory/service.js +2 -5
  24. package/dist/plugins/openbot/context.js +17 -1
  25. package/dist/plugins/openbot/index.js +25 -9
  26. package/dist/plugins/openbot/model.js +59 -0
  27. package/dist/plugins/openbot/runtime.js +39 -56
  28. package/dist/plugins/openbot/system-prompt.js +44 -9
  29. package/dist/plugins/preview/index.js +267 -0
  30. package/dist/plugins/storage/index.js +3 -3
  31. package/dist/plugins/storage/service.js +58 -12
  32. package/dist/plugins/todo/index.js +131 -38
  33. package/dist/plugins/todo/service.js +93 -0
  34. package/dist/plugins/ui/index.js +8 -1
  35. package/dist/services/plugins/model-registry.js +101 -0
  36. package/dist/services/plugins/registry.js +6 -4
  37. package/dist/services/plugins/service.js +2 -6
  38. package/docs/agents.md +20 -2
  39. package/docs/architecture.md +1 -1
  40. package/docs/plugins.md +13 -3
  41. package/docs/templates/AGENT.example.md +1 -1
  42. package/package.json +5 -2
  43. package/pnpm-workspace.yaml +2 -0
  44. package/src/app/bootstrap.ts +43 -0
  45. package/src/app/cli.ts +3 -1
  46. package/src/app/cloud-mode.ts +41 -0
  47. package/src/app/config.ts +71 -19
  48. package/src/app/responding-agent.ts +46 -0
  49. package/src/app/server.ts +55 -9
  50. package/src/app/types.ts +167 -31
  51. package/src/plugins/approval/index.ts +1 -1
  52. package/src/plugins/bash/index.ts +498 -185
  53. package/src/plugins/memory/service.ts +2 -5
  54. package/src/plugins/openbot/context.ts +17 -1
  55. package/src/plugins/openbot/index.ts +27 -11
  56. package/src/plugins/openbot/model.ts +76 -0
  57. package/src/plugins/openbot/runtime.ts +47 -69
  58. package/src/plugins/openbot/system-prompt.ts +44 -9
  59. package/src/plugins/preview/index.ts +323 -0
  60. package/src/plugins/storage/index.ts +5 -8
  61. package/src/plugins/storage/service.ts +79 -15
  62. package/src/plugins/todo/index.ts +166 -0
  63. package/src/plugins/todo/service.ts +123 -0
  64. package/src/plugins/ui/index.ts +8 -1
  65. package/src/services/plugins/domain.ts +2 -0
  66. package/src/services/plugins/model-registry.ts +146 -0
  67. package/src/services/plugins/registry.ts +6 -4
  68. package/src/services/plugins/service.ts +2 -6
  69. package/dist/agents/openbot/index.js +0 -76
  70. package/dist/agents/openbot/middleware/approval.js +0 -132
  71. package/dist/agents/openbot/runtime.js +0 -289
  72. package/dist/agents/openbot/system-prompt.js +0 -32
  73. package/dist/agents/openbot/tools/delegation.js +0 -78
  74. package/dist/agents/openbot/tools/mcp.js +0 -99
  75. package/dist/agents/openbot/tools/shell.js +0 -91
  76. package/dist/agents/openbot/tools/storage.js +0 -75
  77. package/dist/agents/openbot/tools/ui.js +0 -176
  78. package/dist/agents/system.js +0 -33
  79. package/dist/bus/agent-package.js +0 -1
  80. package/dist/bus/plugin.js +0 -1
  81. package/dist/bus/services.js +0 -754
  82. package/dist/bus/types.js +0 -1
  83. package/dist/harness/agent-harness.js +0 -45
  84. package/dist/harness/agent-invoke-run.js +0 -44
  85. package/dist/harness/agent-turn.js +0 -99
  86. package/dist/harness/channel-participants.js +0 -40
  87. package/dist/harness/constants.js +0 -2
  88. package/dist/harness/context-meter.js +0 -97
  89. package/dist/harness/context.js +0 -363
  90. package/dist/harness/dispatch.js +0 -144
  91. package/dist/harness/dispatcher.js +0 -156
  92. package/dist/harness/event-normalizer.js +0 -59
  93. package/dist/harness/history.js +0 -177
  94. package/dist/harness/mcp.js +0 -61
  95. package/dist/harness/orchestration.js +0 -88
  96. package/dist/harness/orchestrator.js +0 -149
  97. package/dist/harness/participants.js +0 -22
  98. package/dist/harness/process.js +0 -29
  99. package/dist/harness/queue-processor.js +0 -187
  100. package/dist/harness/run-harness.js +0 -154
  101. package/dist/harness/run.js +0 -98
  102. package/dist/harness/runtime-factory.js +0 -73
  103. package/dist/harness/runtime.js +0 -57
  104. package/dist/harness/todo-advance.js +0 -93
  105. package/dist/harness/todo-dispatch.js +0 -51
  106. package/dist/harness/todos.js +0 -5
  107. package/dist/harness/turn.js +0 -79
  108. package/dist/harness/types.js +0 -1
  109. package/dist/plugins/ai-sdk/index.js +0 -34
  110. package/dist/plugins/ai-sdk/runtime.js +0 -395
  111. package/dist/plugins/ai-sdk/system-prompt.js +0 -18
  112. package/dist/plugins/ai-sdk.js +0 -331
  113. package/dist/plugins/approval.js +0 -163
  114. package/dist/plugins/delegation.js +0 -108
  115. package/dist/plugins/mcp/index.js +0 -108
  116. package/dist/plugins/mcp.js +0 -140
  117. package/dist/plugins/shell/index.js +0 -100
  118. package/dist/plugins/shell.js +0 -123
  119. package/dist/plugins/storage-tools/index.js +0 -76
  120. package/dist/plugins/storage.js +0 -737
  121. package/dist/plugins/thread-namer/index.js +0 -72
  122. package/dist/plugins/threads/index.js +0 -114
  123. package/dist/plugins/ui.js +0 -211
  124. package/dist/plugins/workflow/index.js +0 -65
  125. package/dist/registry/agents.js +0 -138
  126. package/dist/registry/plugins.js +0 -110
  127. package/dist/services/agent-packages.js +0 -103
  128. package/dist/services/memory.js +0 -152
  129. package/dist/services/plugins.js +0 -98
  130. package/dist/services/storage.js +0 -1028
  131. package/dist/workflow/service.js +0 -106
  132. package/dist/workflow/types.js +0 -3
@@ -2,194 +2,416 @@ import { z } from 'zod';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { randomUUID } from 'node:crypto';
4
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.',
5
+ const DEFAULT_TIMEOUT_MS = 120000;
6
+ const FOREGROUND_DEV_TIMEOUT_MS = 5000;
7
+ const TIMEOUT_EXIT_CODE = 124;
8
+ const MAX_LOG_CHARS = 32000;
9
+ const DEFAULT_SESSION_ID = 'default';
10
+ const DEV_SERVER_READY_PATTERNS = [
11
+ /\bready in \d+/i,
12
+ /\blocal\s+https?:\/\//i,
13
+ /\blistening on\b/i,
14
+ /\bstarted server on\b/i,
15
+ /\bwatching for file changes\b/i,
16
+ ];
17
+ const isBackgroundedCommand = (command) => {
18
+ const trimmed = command.trim();
19
+ return /\s&\s*$/.test(trimmed) || /\bnohup\b/.test(trimmed) || /\bdisown\b/.test(trimmed);
20
+ };
21
+ const looksLikeForegroundDevServer = (command) => {
22
+ const trimmed = command.trim();
23
+ if (isBackgroundedCommand(trimmed))
24
+ return false;
25
+ return (/\b(pnpm|npm|yarn|bun)\s+(run\s+)?dev\b/.test(trimmed) ||
26
+ /\b(astro|vite|next|nuxt|remix)\s+dev\b/.test(trimmed) ||
27
+ /\bpnpm\s+start\b/.test(trimmed) ||
28
+ /\bnpm\s+run\s+start\b/.test(trimmed));
29
+ };
30
+ const resolveExecTimeoutMs = (command) => looksLikeForegroundDevServer(command) ? FOREGROUND_DEV_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
31
+ const isDevServerReady = (output) => DEV_SERVER_READY_PATTERNS.some((pattern) => pattern.test(output));
32
+ const formatTimeoutOutput = (partialOutput, timeoutMs, ready) => {
33
+ const seconds = Math.round(timeoutMs / 1000);
34
+ const statusLine = ready
35
+ ? `[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.]`
36
+ : `[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.]`;
37
+ const body = partialOutput.trim();
38
+ return body ? `${body}\n\n${statusLine}` : statusLine;
39
+ };
40
+ const shellToolDefinitions = {
41
+ shell_exec: {
42
+ 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.',
8
43
  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.'),
44
+ id: z
45
+ .string()
46
+ .describe('Shell session identifier (e.g. "default", "server"). Reuse ids to keep state.'),
47
+ exec_dir: z
48
+ .string()
49
+ .describe('Working directory for this command (absolute path).'),
50
+ command: z.string().describe('Shell command to execute.'),
51
+ }),
52
+ },
53
+ shell_view: {
54
+ description: 'View recent output from a shell session. Use to poll dev-server logs after shell_exec times out or after backgrounding with `&`.',
55
+ inputSchema: z.object({
56
+ id: z.string().describe('Shell session identifier.'),
57
+ }),
58
+ },
59
+ shell_wait: {
60
+ 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).',
61
+ inputSchema: z.object({
62
+ id: z.string().describe('Shell session identifier.'),
63
+ seconds: z.number().int().min(1).max(300).describe('Seconds to wait.'),
14
64
  }),
15
65
  },
16
- bash_stop: {
17
- description: 'Stop the bash session for the current or specified channel.',
66
+ shell_write_to_process: {
67
+ description: 'Write input to a running process in a shell session. Use to answer interactive prompts.',
18
68
  inputSchema: z.object({
19
- channelId: z.string().optional().describe('The channel ID to stop the session for.'),
69
+ id: z.string().describe('Shell session identifier.'),
70
+ input: z.string().describe('Input to send to the process.'),
71
+ press_enter: z
72
+ .boolean()
73
+ .describe('Whether to press Enter after the input.'),
20
74
  }),
21
75
  },
22
- bash_list_sessions: {
23
- description: 'List all active bash sessions.',
24
- inputSchema: z.object({}),
76
+ shell_kill_process: {
77
+ description: 'Send interrupt to the active process in a shell session (e.g. stop a dev server).',
78
+ inputSchema: z.object({
79
+ id: z.string().describe('Shell session identifier.'),
80
+ }),
25
81
  },
26
82
  };
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: '' },
83
+ const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
84
+ const resolveCwd = (context, execDir) => {
85
+ const raw = (typeof execDir === 'string' && execDir.trim()) ||
86
+ context.state.channelDetails?.cwd ||
87
+ process.cwd();
88
+ return resolvePath(raw);
89
+ };
90
+ const sessionKey = (channelId, id) => `${channelId}:${id}`;
91
+ class ShellSession {
92
+ constructor(channelId, id, cwd) {
93
+ this.channelId = channelId;
94
+ this.id = id;
95
+ this.cwd = cwd;
96
+ this.output = '';
97
+ this.process = null;
98
+ this.execQueue = Promise.resolve();
99
+ this.spawn();
100
+ }
101
+ spawn() {
102
+ this.process = spawn('bash', [], {
103
+ cwd: this.cwd,
104
+ env: process.env,
34
105
  stdio: ['pipe', 'pipe', 'pipe'],
35
106
  });
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);
107
+ this.process.stdout?.on('data', (chunk) => this.append(chunk.toString()));
108
+ this.process.stderr?.on('data', (chunk) => this.append(chunk.toString()));
109
+ this.process.on('exit', () => {
110
+ this.process = null;
111
+ this.rejectPending(new Error('Shell session exited unexpectedly'));
112
+ });
113
+ this.process.on('error', (error) => {
114
+ this.rejectPending(error);
46
115
  });
47
- childProcess.on('exit', () => {
48
- sessions.delete(channelId);
116
+ }
117
+ rejectPending(error) {
118
+ if (!this.pending)
119
+ return;
120
+ clearTimeout(this.pending.timer);
121
+ this.pending.reject(error);
122
+ this.pending = undefined;
123
+ }
124
+ append(chunk) {
125
+ this.output += chunk;
126
+ if (this.output.length > MAX_LOG_CHARS) {
127
+ this.output = this.output.slice(-MAX_LOG_CHARS);
128
+ }
129
+ if (!this.pending)
130
+ return;
131
+ const tail = this.output.slice(this.pending.startLen);
132
+ const markerIndex = tail.indexOf(this.pending.marker);
133
+ if (markerIndex === -1)
134
+ return;
135
+ const afterMarker = tail.slice(markerIndex + this.pending.marker.length);
136
+ const match = afterMarker.match(/^:(\d+)/);
137
+ const exitCode = match ? Number.parseInt(match[1], 10) : 0;
138
+ const output = tail.slice(0, markerIndex).trimEnd();
139
+ clearTimeout(this.pending.timer);
140
+ this.pending.resolve({ exitCode, output });
141
+ this.pending = undefined;
142
+ }
143
+ ensureProcess() {
144
+ if (!this.process?.stdin) {
145
+ this.spawn();
146
+ }
147
+ if (!this.process?.stdin) {
148
+ throw new Error('Failed to start shell session');
149
+ }
150
+ return this.process;
151
+ }
152
+ enqueue(fn) {
153
+ const next = this.execQueue.then(fn, fn);
154
+ this.execQueue = next.catch(() => undefined);
155
+ return next;
156
+ }
157
+ view() {
158
+ return this.output.slice(-8000);
159
+ }
160
+ async exec(command, execDir) {
161
+ return this.enqueue(() => this.execInternal(command, execDir));
162
+ }
163
+ execInternal(command, execDir) {
164
+ const process = this.ensureProcess();
165
+ const marker = `__OPENBOT_${randomUUID().replace(/-/g, '')}__`;
166
+ const script = `cd ${shellQuote(execDir)} && ${command}; printf '\\n${marker}:%s\\n' "$?"`;
167
+ const timeoutMs = resolveExecTimeoutMs(command);
168
+ return new Promise((resolve, reject) => {
169
+ if (this.pending) {
170
+ reject(new Error('Shell session is busy'));
171
+ return;
172
+ }
173
+ const startLen = this.output.length;
174
+ const timer = setTimeout(() => {
175
+ if (!this.pending)
176
+ return;
177
+ const { startLen: pendingStartLen, timeoutMs: pendingTimeoutMs, resolve: pendingResolve } = this.pending;
178
+ clearTimeout(this.pending.timer);
179
+ this.pending = undefined;
180
+ const partial = this.output.slice(pendingStartLen).trimEnd();
181
+ const ready = isDevServerReady(partial);
182
+ pendingResolve({
183
+ exitCode: TIMEOUT_EXIT_CODE,
184
+ output: formatTimeoutOutput(partial, pendingTimeoutMs, ready),
185
+ timedOut: true,
186
+ stillRunning: true,
187
+ });
188
+ }, timeoutMs);
189
+ this.pending = { marker, startLen, timeoutMs, resolve, reject, timer };
190
+ try {
191
+ process.stdin.write(`${script}\n`);
192
+ }
193
+ catch (error) {
194
+ clearTimeout(timer);
195
+ this.pending = undefined;
196
+ reject(error instanceof Error ? error : new Error(String(error)));
197
+ }
49
198
  });
50
199
  }
200
+ async wait(seconds) {
201
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
202
+ return this.view();
203
+ }
204
+ write(input, pressEnter) {
205
+ const process = this.ensureProcess();
206
+ process.stdin.write(pressEnter ? `${input}\n` : input);
207
+ }
208
+ kill() {
209
+ const process = this.ensureProcess();
210
+ process.stdin.write('\x03');
211
+ }
212
+ destroy() {
213
+ this.rejectPending(new Error('Shell session closed'));
214
+ if (!this.process)
215
+ return;
216
+ try {
217
+ this.process.kill('SIGTERM');
218
+ }
219
+ catch {
220
+ // ignore
221
+ }
222
+ this.process = null;
223
+ }
224
+ }
225
+ const sessions = new Map();
226
+ const getSession = (channelId, id, defaultCwd) => {
227
+ const key = sessionKey(channelId, id);
228
+ const existing = sessions.get(key);
229
+ if (existing)
230
+ return existing;
231
+ const session = new ShellSession(channelId, id, defaultCwd);
232
+ sessions.set(key, session);
51
233
  return session;
52
234
  };
53
- const bashPluginRuntime = () => (builder) => {
54
- builder.on('action:bash', async function* (event, context) {
55
- const { command, restart } = event.data;
235
+ const destroySessionsForChannel = (channelId) => {
236
+ for (const [key, session] of sessions.entries()) {
237
+ if (!key.startsWith(`${channelId}:`))
238
+ continue;
239
+ session.destroy();
240
+ sessions.delete(key);
241
+ }
242
+ };
243
+ const formatResult = (output, extra) => ({
244
+ success: true,
245
+ output: output.trim() || '(no output)',
246
+ ...extra,
247
+ });
248
+ const resolveShellWidgetId = (event) => event.meta?.toolCallId || randomUUID();
249
+ const formatShellWidgetBody = (input, output) => {
250
+ const inputText = JSON.stringify(input, null, 2);
251
+ const outputText = output.trim() || '(no output)';
252
+ return `Input:\n${inputText}\n\nOutput:\n${outputText}`;
253
+ };
254
+ function* emitShellWidgetPending(event, context, tool, input, widgetId) {
255
+ const threadId = event.meta?.threadId || context.state.threadId;
256
+ yield {
257
+ type: 'client:ui:widget',
258
+ data: {
259
+ widgetId,
260
+ kind: 'message',
261
+ title: tool,
262
+ body: formatShellWidgetBody(input, '(running...)'),
263
+ display: 'collapsed',
264
+ metadata: {
265
+ type: 'shell:tool',
266
+ tool,
267
+ input,
268
+ status: 'running',
269
+ },
270
+ },
271
+ meta: { agentId: context.state.agentId, threadId },
272
+ };
273
+ }
274
+ function* emitShellToolResult(event, context, tool, input, result, widgetId) {
275
+ const threadId = event.meta?.threadId || context.state.threadId;
276
+ const output = String(result.output ?? '');
277
+ yield {
278
+ type: 'client:ui:widget',
279
+ data: {
280
+ widgetId,
281
+ kind: 'message',
282
+ title: tool,
283
+ body: formatShellWidgetBody(input, output),
284
+ state: result.success ? 'submitted' : 'error',
285
+ display: 'collapsed',
286
+ metadata: {
287
+ type: 'shell:tool',
288
+ tool,
289
+ input,
290
+ output,
291
+ success: result.success,
292
+ status: result.success ? 'done' : 'error',
293
+ },
294
+ },
295
+ meta: { agentId: context.state.agentId, threadId },
296
+ };
297
+ const { output: _output, ...resultData } = result;
298
+ yield {
299
+ type: `action:${tool}:result`,
300
+ data: { ...resultData, output },
301
+ meta: event.meta,
302
+ };
303
+ }
304
+ async function* runShellTool(event, context, tool, input, execute) {
305
+ const widgetId = resolveShellWidgetId(event);
306
+ yield* emitShellWidgetPending(event, context, tool, input, widgetId);
307
+ try {
308
+ const result = await execute();
309
+ yield* emitShellToolResult(event, context, tool, input, result, widgetId);
310
+ }
311
+ catch (error) {
312
+ const message = error instanceof Error ? error.message : 'Unknown shell error';
313
+ yield* emitShellToolResult(event, context, tool, input, { success: false, output: message, error: message }, widgetId);
314
+ }
315
+ }
316
+ const shellPluginRuntime = () => (builder) => {
317
+ builder.on('action:shell_exec', async function* (event, context) {
318
+ const { id, exec_dir, command } = event.data;
319
+ const sessionId = (id || DEFAULT_SESSION_ID).trim() || DEFAULT_SESSION_ID;
56
320
  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
- };
321
+ const input = {
322
+ id: sessionId,
323
+ exec_dir: exec_dir ?? resolveCwd(context),
324
+ command: command ?? '',
325
+ };
326
+ if (!command?.trim()) {
327
+ yield* runShellTool(event, context, 'shell_exec', input, async () => ({
328
+ success: false,
329
+ output: 'command is required',
330
+ }));
331
+ return;
118
332
  }
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,
333
+ yield* runShellTool(event, context, 'shell_exec', input, async () => {
334
+ const execDir = resolveCwd(context, exec_dir);
335
+ const session = getSession(channelId, sessionId, execDir);
336
+ const result = await session.exec(command, execDir);
337
+ const success = result.timedOut ? isDevServerReady(result.output) : result.exitCode === 0;
338
+ return {
339
+ success,
340
+ exitCode: result.exitCode,
341
+ output: result.output.trim() || '(no output)',
342
+ ...(result.timedOut && { timedOut: true, stillRunning: result.stillRunning }),
133
343
  };
134
- }
344
+ });
135
345
  });
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
- };
346
+ builder.on('action:shell_view', async function* (event, context) {
347
+ const sessionId = (event.data?.id || DEFAULT_SESSION_ID).trim();
348
+ const channelId = context.state.channelId;
349
+ const defaultCwd = resolveCwd(context);
350
+ const input = { id: sessionId };
351
+ yield* runShellTool(event, context, 'shell_view', input, async () => {
352
+ const session = getSession(channelId, sessionId, defaultCwd);
353
+ return formatResult(session.view());
354
+ });
149
355
  });
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,
356
+ builder.on('action:shell_wait', async function* (event, context) {
357
+ const { id, seconds } = event.data;
358
+ const sessionId = (id || DEFAULT_SESSION_ID).trim();
359
+ const channelId = context.state.channelId;
360
+ const defaultCwd = resolveCwd(context);
361
+ const waitedSeconds = seconds ?? 5;
362
+ const input = { id: sessionId, seconds: waitedSeconds };
363
+ yield* runShellTool(event, context, 'shell_wait', input, async () => {
364
+ const session = getSession(channelId, sessionId, defaultCwd);
365
+ const output = await session.wait(waitedSeconds);
366
+ return formatResult(output, { waitedSeconds });
367
+ });
368
+ });
369
+ builder.on('action:shell_write_to_process', async function* (event, context) {
370
+ const { id, input: textInput, press_enter } = event.data;
371
+ const sessionId = (id || DEFAULT_SESSION_ID).trim();
372
+ const channelId = context.state.channelId;
373
+ const defaultCwd = resolveCwd(context);
374
+ const toolInput = {
375
+ id: sessionId,
376
+ input: textInput ?? '',
377
+ press_enter: press_enter ?? true,
185
378
  };
379
+ if (!textInput?.trim()) {
380
+ yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => ({
381
+ success: false,
382
+ output: 'input is required',
383
+ }));
384
+ return;
385
+ }
386
+ yield* runShellTool(event, context, 'shell_write_to_process', toolInput, async () => {
387
+ const session = getSession(channelId, sessionId, defaultCwd);
388
+ session.write(textInput, toolInput.press_enter);
389
+ return { success: true, output: 'Input sent to shell session.' };
390
+ });
391
+ });
392
+ builder.on('action:shell_kill_process', async function* (event, context) {
393
+ const sessionId = (event.data?.id || DEFAULT_SESSION_ID).trim();
394
+ const channelId = context.state.channelId;
395
+ const defaultCwd = resolveCwd(context);
396
+ const input = { id: sessionId };
397
+ yield* runShellTool(event, context, 'shell_kill_process', input, async () => {
398
+ const session = getSession(channelId, sessionId, defaultCwd);
399
+ session.kill();
400
+ return { success: true, output: 'Interrupt sent to shell session.' };
401
+ });
402
+ });
403
+ builder.on('action:delete_channel', async function* (event) {
404
+ const channelId = event.data?.channelId;
405
+ if (channelId) {
406
+ destroySessionsForChannel(channelId);
407
+ }
186
408
  });
187
409
  };
188
410
  export const bashPlugin = {
189
411
  id: 'bash',
190
- name: 'Bash',
191
- description: 'Stateful bash session for the channel.',
192
- toolDefinitions: bashToolDefinitions,
193
- factory: () => bashPluginRuntime(),
412
+ name: 'Shell',
413
+ description: 'Stateful shell sessions for the channel workspace (Manus-style).',
414
+ toolDefinitions: shellToolDefinitions,
415
+ factory: () => shellPluginRuntime(),
194
416
  };
195
417
  export default bashPlugin;
@@ -1,13 +1,10 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../../app/config.js';
4
+ import { getBaseDir } from '../../app/config.js';
5
5
  const DEFAULT_LIMIT = 50;
6
6
  const MAX_LIMIT = 500;
7
- const getMemoryDir = () => {
8
- const config = loadConfig();
9
- return path.join(resolvePath(config.baseDir || DEFAULT_BASE_DIR), 'memory');
10
- };
7
+ const getMemoryDir = () => path.join(getBaseDir(), 'memory');
11
8
  const getLogPath = () => path.join(getMemoryDir(), 'log.jsonl');
12
9
  const ensureDir = async () => {
13
10
  await fs.mkdir(getMemoryDir(), { recursive: true });
@@ -1,4 +1,5 @@
1
1
  import { OPENBOT_SYSTEM_PROMPT } from './system-prompt.js';
2
+ import { todoService } from '../todo/service.js';
2
3
  export const DEFAULT_CONTEXT_BUDGET = 8000;
3
4
  export const MAX_CONTEXT_FILES = 50;
4
5
  /**
@@ -43,7 +44,22 @@ export async function buildContext(state, storage) {
43
44
  env += `- Thread: ${threadDetails?.name || threadId}\n`;
44
45
  }
45
46
  sections.push(env);
46
- // 2.5 Installed Agents
47
+ // 2.5 Thread todos
48
+ if (channelId && threadId) {
49
+ try {
50
+ const list = await todoService.getTodos({ channelId, threadId });
51
+ if (list.items.length > 0) {
52
+ const formatted = list.items
53
+ .map((t) => `- [${t.status}] (${t.id}) ${t.content}`)
54
+ .join('\n');
55
+ sections.push(`## TODOS\n${formatted}`);
56
+ }
57
+ }
58
+ catch (error) {
59
+ console.warn('[context] Failed to fetch todos:', error);
60
+ }
61
+ }
62
+ // 2.6 Installed Agents
47
63
  if (allAgents.length > 0) {
48
64
  const formatted = allAgents
49
65
  .map((a) => `- ${a.id}: ${a.name}${a.description ? ` - ${a.description}` : ''}`)