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
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { DEFAULT_CHANNELS_DIR, getBaseDir, resolvePath } from '../../app/config.js';
4
+ export const MAX_TODO_ITEMS = 20;
5
+ const TODO_STATUSES = new Set([
6
+ 'pending',
7
+ 'in_progress',
8
+ 'completed',
9
+ 'cancelled',
10
+ ]);
11
+ const getThreadDir = (channelId, threadId) => {
12
+ const base = resolvePath(path.join(getBaseDir(), DEFAULT_CHANNELS_DIR, channelId));
13
+ return path.join(base, 'threads', threadId);
14
+ };
15
+ const getTodosPath = (channelId, threadId) => path.join(getThreadDir(channelId, threadId), 'todos.json');
16
+ const emptyList = () => ({
17
+ items: [],
18
+ updatedAt: new Date().toISOString(),
19
+ });
20
+ /**
21
+ * Validate and normalize a todo list write. Throws on invalid input.
22
+ * Enforces: non-empty content, known statuses, unique ids, max size,
23
+ * and at most one `in_progress` item.
24
+ */
25
+ export function validateTodoItems(items) {
26
+ if (!Array.isArray(items)) {
27
+ throw new Error('todos must be an array');
28
+ }
29
+ if (items.length > MAX_TODO_ITEMS) {
30
+ throw new Error(`At most ${MAX_TODO_ITEMS} todos allowed`);
31
+ }
32
+ const seen = new Set();
33
+ let inProgressCount = 0;
34
+ const normalized = [];
35
+ for (const raw of items) {
36
+ if (!raw || typeof raw !== 'object') {
37
+ throw new Error('Each todo must be an object');
38
+ }
39
+ const id = typeof raw.id === 'string' ? raw.id.trim() : '';
40
+ const content = typeof raw.content === 'string' ? raw.content.trim() : '';
41
+ const status = raw.status;
42
+ if (!id)
43
+ throw new Error('Each todo requires a non-empty id');
44
+ if (!content)
45
+ throw new Error(`Todo "${id}" requires non-empty content`);
46
+ if (!TODO_STATUSES.has(status)) {
47
+ throw new Error(`Todo "${id}" has invalid status "${String(status)}"; expected pending|in_progress|completed|cancelled`);
48
+ }
49
+ if (seen.has(id))
50
+ throw new Error(`Duplicate todo id "${id}"`);
51
+ seen.add(id);
52
+ if (status === 'in_progress')
53
+ inProgressCount += 1;
54
+ normalized.push({ id, content, status: status });
55
+ }
56
+ if (inProgressCount > 1) {
57
+ throw new Error('At most one todo may be in_progress');
58
+ }
59
+ return normalized;
60
+ }
61
+ export const todoService = {
62
+ getTodos: async (args) => {
63
+ const filePath = getTodosPath(args.channelId, args.threadId);
64
+ try {
65
+ const raw = await fs.readFile(filePath, 'utf-8');
66
+ const parsed = JSON.parse(raw);
67
+ const items = Array.isArray(parsed.items) ? parsed.items : [];
68
+ return {
69
+ items,
70
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
71
+ };
72
+ }
73
+ catch (e) {
74
+ if (e?.code === 'ENOENT')
75
+ return emptyList();
76
+ throw e;
77
+ }
78
+ },
79
+ writeTodos: async (args) => {
80
+ const items = validateTodoItems(args.items);
81
+ const list = {
82
+ items,
83
+ updatedAt: new Date().toISOString(),
84
+ };
85
+ const threadDir = getThreadDir(args.channelId, args.threadId);
86
+ await fs.mkdir(threadDir, { recursive: true });
87
+ const filePath = getTodosPath(args.channelId, args.threadId);
88
+ const tmp = `${filePath}.tmp`;
89
+ await fs.writeFile(tmp, `${JSON.stringify(list, null, 2)}\n`, 'utf-8');
90
+ await fs.rename(tmp, filePath);
91
+ return list;
92
+ },
93
+ };
@@ -36,7 +36,14 @@ export const uiPlugin = {
36
36
  id: z.string(),
37
37
  label: z.string(),
38
38
  description: z.string().optional(),
39
- status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
39
+ status: z
40
+ .string()
41
+ .optional()
42
+ .describe('Status label shown on the item (e.g. "Pending", "Shipped").'),
43
+ statusVariant: z
44
+ .enum(['default', 'success', 'warning', 'danger', 'info'])
45
+ .optional()
46
+ .describe('Semantic hint for status badge coloring in the client.'),
40
47
  metadata: z.record(z.string(), z.any()).optional()
41
48
  })).optional().describe('Required for kind="list". List of items to display.'),
42
49
  submitLabel: z.string().optional().describe('Label for the primary action button (e.g. "Submit", "Save").')
@@ -0,0 +1,101 @@
1
+ import { DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig, } from '../../app/config.js';
2
+ import { isCloudMode } from '../../app/cloud-mode.js';
3
+ let cachedRegistry = null;
4
+ let cacheUrl = null;
5
+ function getRegistryUrl() {
6
+ const { marketplaceRegistryUrl } = loadConfig();
7
+ return marketplaceRegistryUrl?.trim() || DEFAULT_MARKETPLACE_REGISTRY_URL;
8
+ }
9
+ export async function resolveModelRegistry() {
10
+ const url = getRegistryUrl();
11
+ if (cachedRegistry && cacheUrl === url)
12
+ return cachedRegistry;
13
+ try {
14
+ const res = await fetch(url, {
15
+ headers: { Accept: 'application/json' },
16
+ signal: AbortSignal.timeout(15000),
17
+ });
18
+ if (!res.ok) {
19
+ throw new Error(`Registry HTTP ${res.status} ${res.statusText}`);
20
+ }
21
+ cachedRegistry = (await res.json());
22
+ cacheUrl = url;
23
+ return cachedRegistry;
24
+ }
25
+ catch (err) {
26
+ console.warn('[model-registry] fetch failed:', err instanceof Error ? err.message : err);
27
+ return { providers: {} };
28
+ }
29
+ }
30
+ export function listModelOptionsFromRegistry(registry) {
31
+ const providers = registry.providers ?? {};
32
+ const out = [];
33
+ for (const [providerId, provider] of Object.entries(providers)) {
34
+ for (const model of provider.models ?? []) {
35
+ out.push({
36
+ value: `${providerId}/${model.id}`,
37
+ label: `${provider.label} — ${model.label}`,
38
+ description: model.description,
39
+ provider: providerId,
40
+ });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ export async function listRegistryModelOptions() {
46
+ const registry = await resolveModelRegistry();
47
+ return listModelOptionsFromRegistry(registry);
48
+ }
49
+ export function listApiKeyProvidersFromRegistry(registry) {
50
+ const providers = registry.providers ?? {};
51
+ return Object.entries(providers).map(([id, provider]) => ({
52
+ id,
53
+ label: provider.label,
54
+ }));
55
+ }
56
+ function pickDefaultModelValue(options) {
57
+ if (options.length === 0)
58
+ return undefined;
59
+ const values = options.map((option) => option.value);
60
+ const preferred = isCloudMode()
61
+ ? values.find((value) => value.startsWith('openbot/'))
62
+ : values.find((value) => value.startsWith('openai/'));
63
+ return preferred ?? values[0];
64
+ }
65
+ export function enrichOpenbotPluginDescriptor(descriptor, modelOptions) {
66
+ if (descriptor.id !== 'openbot' || !descriptor.configSchema)
67
+ return descriptor;
68
+ const modelProperty = descriptor.configSchema.properties.model;
69
+ if (!modelProperty)
70
+ return descriptor;
71
+ const values = modelOptions.map((option) => option.value);
72
+ const staticDefault = typeof modelProperty.default === 'string' ? modelProperty.default : undefined;
73
+ const defaultModel = staticDefault && values.includes(staticDefault)
74
+ ? staticDefault
75
+ : pickDefaultModelValue(modelOptions) ?? staticDefault;
76
+ const nextModelProperty = {
77
+ ...modelProperty,
78
+ description: 'Model from the hosted marketplace registry.',
79
+ };
80
+ if (values.length > 0) {
81
+ nextModelProperty.enum = values;
82
+ nextModelProperty.options = modelOptions.map((option) => ({
83
+ label: option.label,
84
+ value: option.value,
85
+ description: option.description,
86
+ }));
87
+ }
88
+ if (defaultModel) {
89
+ nextModelProperty.default = defaultModel;
90
+ }
91
+ return {
92
+ ...descriptor,
93
+ configSchema: {
94
+ ...descriptor.configSchema,
95
+ properties: {
96
+ ...descriptor.configSchema.properties,
97
+ model: nextModelProperty,
98
+ },
99
+ },
100
+ };
101
+ }
@@ -6,10 +6,12 @@ import { bashPlugin } from '../../plugins/bash/index.js';
6
6
  import { storagePlugin } from '../../plugins/storage/index.js';
7
7
  import { approvalPlugin } from '../../plugins/approval/index.js';
8
8
  import { memoryPlugin } from '../../plugins/memory/index.js';
9
+ import { todoPlugin } from '../../plugins/todo/index.js';
9
10
  import { delegationPlugin } from '../../plugins/delegation/index.js';
10
11
  import { uiPlugin } from '../../plugins/ui/index.js';
11
12
  import { pluginManagerPlugin } from '../../plugins/plugin-manager/index.js';
12
- import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../../app/config.js';
13
+ import { previewPlugin } from '../../plugins/preview/index.js';
14
+ import { DEFAULT_PLUGINS_DIR, getBaseDir } from '../../app/config.js';
13
15
  import { invalidatePlugin as clearResolvedPluginEntry, loadedCommunityPlugins, resolvedPluginCache, } from './plugin-cache.js';
14
16
  let pluginsDir = null;
15
17
  const BUILT_IN = {
@@ -18,9 +20,11 @@ const BUILT_IN = {
18
20
  [storagePlugin.id]: storagePlugin,
19
21
  [approvalPlugin.id]: approvalPlugin,
20
22
  [memoryPlugin.id]: memoryPlugin,
23
+ [todoPlugin.id]: todoPlugin,
21
24
  [delegationPlugin.id]: delegationPlugin,
22
25
  [uiPlugin.id]: uiPlugin,
23
26
  [pluginManagerPlugin.id]: pluginManagerPlugin,
27
+ [previewPlugin.id]: previewPlugin,
24
28
  };
25
29
  /** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
26
30
  export function parsePluginModule(module) {
@@ -51,9 +55,7 @@ export function initPlugins(dir) {
51
55
  pluginsDir = dir;
52
56
  }
53
57
  else {
54
- const config = loadConfig();
55
- const baseDir = config.baseDir || DEFAULT_BASE_DIR;
56
- pluginsDir = path.join(resolvePath(baseDir), DEFAULT_PLUGINS_DIR);
58
+ pluginsDir = path.join(getBaseDir(), DEFAULT_PLUGINS_DIR);
57
59
  }
58
60
  }
59
61
  /**
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { exec } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
- import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig, resolvePath, } from '../../app/config.js';
6
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_MARKETPLACE_REGISTRY_URL, getBaseDir, loadConfig, } from '../../app/config.js';
7
7
  import { invalidatePlugin } from './plugin-cache.js';
8
8
  const execAsync = promisify(exec);
9
9
  const DEFAULT_MARKETPLACE_AGENTS = [];
@@ -136,11 +136,7 @@ export async function resolveMarketplaceAgentList() {
136
136
  const registry = await resolveMarketplaceRegistry();
137
137
  return registry.agents;
138
138
  }
139
- const getPluginsDir = () => {
140
- const config = loadConfig();
141
- const baseDir = resolvePath(config.baseDir || DEFAULT_BASE_DIR);
142
- return path.join(baseDir, DEFAULT_PLUGINS_DIR);
143
- };
139
+ const getPluginsDir = () => path.join(getBaseDir(), DEFAULT_PLUGINS_DIR);
144
140
  /**
145
141
  * Lifecycle for community-built plugins distributed via npm.
146
142
  * Each plugin is installed to `<plugins>/<npm-name>/` and is identified
package/docs/agents.md CHANGED
@@ -36,8 +36,8 @@ A runtime plugin is one that handles `agent:invoke` (the LLM loop). Without
36
36
  one, the agent will not respond to user input. Built-in runtime plugins:
37
37
 
38
38
  - `openbot` — the standard, opinionated OpenBot agent runtime. It is
39
- **batteries-included** and provides inbuilt tools (bash, memory, storage,
40
- delegation, and approval).
39
+ **batteries-included** and provides inbuilt tools (bash, memory, todo,
40
+ storage, delegation, and approval).
41
41
  - `claude-code` — runs Claude inside the Claude Agent SDK with its own tools.
42
42
  - `gemini-cli` — spawns Google's `gemini` CLI in headless mode.
43
43
 
@@ -66,6 +66,24 @@ On every LLM turn the runtime injects matching memories into the system prompt
66
66
  via the `MemoryProvider` in the context engine, so the model treats remembered
67
67
  facts as ground truth without needing to call `recall` first.
68
68
 
69
+ ## Todos
70
+
71
+ The `todo` plugin gives agents `todo_write` and `todo_read` for thread-scoped
72
+ multi-step task tracking. The list is stored at
73
+ `~/.openbot/channels/<channelId>/threads/<threadId>/todos.json`.
74
+
75
+ Rules enforced by the runtime:
76
+
77
+ - At most 20 items
78
+ - At most one item may be `in_progress`
79
+ - Each `todo_write` replaces the full list
80
+
81
+ On every LLM turn the current list is injected into context as `## TODOS`, so
82
+ the model usually does not need `todo_read`. Each successful `todo_write` also
83
+ emits a `client:ui:widget` list (`widgetId` `todos:<runId>`, stable for the
84
+ agent run so multiple `todo_write` calls update one widget), and both tools return
85
+ `data.output` for model feedback.
86
+
69
87
  ## Installing community agents
70
88
 
71
89
  Marketplace entries reference plugin ids (built-in or npm package names).
@@ -5,7 +5,7 @@ OpenBot is an orchestration platform built on a modular, event-driven architectu
5
5
  ## Core Components
6
6
 
7
7
  ### 1. Orchestrator & routing
8
- The orchestrator is the execution entry point for agent work: it normalizes incoming events, runs the queue processor (todo-driven assignees), builds per-agent Melony runtimes, and streams emitted events back to callers (for example storage and SSE). Routing across the agent network uses:
8
+ The orchestrator is the execution entry point for agent work: it normalizes incoming events, builds per-agent Melony runtimes, and streams emitted events back to callers (for example storage and SSE). Routing across the agent network uses:
9
9
 
10
10
  1. **Command Prefix** — Explicit delegation to a specific agent (e.g., `/os list files`).
11
11
  2. **DM context** — Direct communication with a specific agent.
package/docs/plugins.md CHANGED
@@ -39,6 +39,14 @@ same agent into a single map and passes it to every plugin via `context.tools`.
39
39
  Runtime plugins read it; tool plugins ignore it. First plugin wins on tool
40
40
  name collisions.
41
41
 
42
+ ### Tool results
43
+
44
+ When a tool handler finishes, yield `action:<tool>:result` with
45
+ `data.output: string`. The OpenBot runtime feeds that string back into the
46
+ model as the tool result (`history.ts`). Structured fields (e.g. `list`,
47
+ `success`) are fine alongside it, but without `output` the model only sees
48
+ `"No output"`.
49
+
42
50
  ## Built-in plugins
43
51
 
44
52
  | Id | Role | Notes |
@@ -49,6 +57,7 @@ name collisions.
49
57
  | `bash` | Tool | `bash` (inbuilt in `openbot`) |
50
58
  | `storage` | Tool | `create_channel`, `patch_*`, ... (inbuilt in `openbot`) |
51
59
  | `memory` | Tool | `remember`, `recall`, `forget` (inbuilt in `openbot`) |
60
+ | `todo` | Tool | `todo_write`, `todo_read` (inbuilt in `openbot`) |
52
61
  | `plugin-manager`| Infra | Marketplace list, npm plugin install/uninstall, agent install |
53
62
 
54
63
  ## Batteries-included: `openbot` runtime
@@ -58,6 +67,7 @@ to be isolated and self-contained, providing a core ecosystem of inbuilt tools:
58
67
 
59
68
  - **Bash**: Stateful system tasks and file operations.
60
69
  - **Memory**: Long-term durable fact storage.
70
+ - **Todo**: Thread-scoped multi-step task checklist.
61
71
  - **Storage**: Channel and thread management.
62
72
  - **Delegation**: Calling upon other specialized agents.
63
73
  - **Approval**: Gating protected actions behind UI confirmation.
@@ -71,7 +81,7 @@ plugins:
71
81
  config:
72
82
  model: openai/gpt-4o-mini
73
83
  approval:
74
- actions: [action:bash, action:create_channel]
84
+ actions: [action:shell_exec, action:create_channel]
75
85
  ```
76
86
 
77
87
  A community plugin is just an npm package whose default export matches the
@@ -90,11 +100,11 @@ On first use OpenBot installs the package into
90
100
 
91
101
  ## Approval plugin
92
102
 
93
- The `approval` plugin gates protected tool calls behind a UI confirmation widget. By default, it gates `action:bash`.
103
+ The `approval` plugin gates protected tool calls behind a UI confirmation widget. By default, it gates `action:shell_exec`.
94
104
 
95
105
  ```yaml
96
106
  plugins:
97
107
  - id: approval
98
108
  config:
99
- actions: [action:bash]
109
+ actions: [action:shell_exec]
100
110
  ```
@@ -28,7 +28,7 @@ plugins:
28
28
  - id: storage
29
29
  - id: approval
30
30
  config:
31
- actions: [action:bash]
31
+ actions: [action:shell_exec]
32
32
  ---
33
33
 
34
34
  <!--
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.4.7",
3
+ "version": "0.5.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -9,7 +9,9 @@
9
9
  "scripts": {
10
10
  "dev": "tsx watch src/app/cli.ts start",
11
11
  "build": "tsc && mkdir -p dist/assets && cp src/assets/icon.svg dist/assets/icon.svg",
12
- "start": "node dist/app/cli.js start"
12
+ "start": "node dist/app/cli.js start",
13
+ "format": "prettier --write .",
14
+ "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.4"
13
15
  },
14
16
  "bin": {
15
17
  "openbot": "./dist/app/cli.js"
@@ -34,6 +36,7 @@
34
36
  "@types/express": "^4.17.21",
35
37
  "@types/js-yaml": "^4.0.9",
36
38
  "@types/node": "^20.10.1",
39
+ "prettier": "^3.4.2",
37
40
  "tsx": "^4.21.0",
38
41
  "typescript": "^5.9.3"
39
42
  }
@@ -0,0 +1,2 @@
1
+ allowBuilds:
2
+ esbuild: true
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ CONFIG_FILE,
5
+ DEFAULT_AGENTS_DIR,
6
+ DEFAULT_CHANNELS_DIR,
7
+ DEFAULT_PLUGINS_DIR,
8
+ getBaseDir,
9
+ getChannelsWorkspaceRoot,
10
+ getConfigDir,
11
+ type OpenBotconfig,
12
+ } from './config.js';
13
+
14
+ /** Ensure data directories and a minimal config exist (first boot on a Fly volume). */
15
+ export async function bootstrap(): Promise<void> {
16
+ const baseDir = getBaseDir();
17
+ const configDir = getConfigDir();
18
+
19
+ await fs.mkdir(path.join(baseDir, DEFAULT_AGENTS_DIR), { recursive: true });
20
+ await fs.mkdir(path.join(baseDir, DEFAULT_PLUGINS_DIR), { recursive: true });
21
+ await fs.mkdir(path.join(baseDir, DEFAULT_CHANNELS_DIR), { recursive: true });
22
+ await fs.mkdir(getChannelsWorkspaceRoot(), { recursive: true });
23
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
24
+
25
+ const configPath = path.join(configDir, CONFIG_FILE);
26
+ try {
27
+ await fs.access(configPath);
28
+ } catch {
29
+ const initial: OpenBotconfig = { baseDir };
30
+
31
+ const publicUrl = process.env.OPENBOT_PUBLIC_URL?.trim();
32
+ if (publicUrl) {
33
+ initial.publicUrl = publicUrl.replace(/\/$/, '');
34
+ }
35
+
36
+ const workspaceDir = process.env.OPENBOT_CHANNELS_WORKSPACE_DIR?.trim();
37
+ if (workspaceDir) {
38
+ initial.channelsWorkspaceDir = getChannelsWorkspaceRoot();
39
+ }
40
+
41
+ await fs.writeFile(configPath, JSON.stringify(initial, null, 2), { mode: 0o600 });
42
+ }
43
+ }
package/src/app/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { bootstrap } from './bootstrap.js';
3
4
  import { startServer } from './server.js';
4
5
 
5
6
  const program = new Command();
@@ -25,13 +26,14 @@ function checkNodeVersion() {
25
26
 
26
27
  checkNodeVersion();
27
28
 
28
- program.name('openbot').description('OpenBot CLI').version('0.4.7');
29
+ program.name('openbot').description('OpenBot CLI').version('0.5.4');
29
30
 
30
31
  program
31
32
  .command('start')
32
33
  .description('Start the OpenBot harness')
33
34
  .option('-p, --port <number>', 'Port to listen on')
34
35
  .action(async (options) => {
36
+ await bootstrap();
35
37
  await startServer(options);
36
38
  });
37
39
 
@@ -0,0 +1,41 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
2
+
3
+ /** Platform-managed OpenBot (Fly + integrations proxy). Self-hosted when unset. */
4
+ export const isCloudMode = (): boolean => process.env.OPENBOT_CLOUD_MODE === '1';
5
+
6
+ /** Platform-managed coordinator model for cloud workspaces. */
7
+ export const COORDINATOR_MODEL = 'openbot/coordinator-1';
8
+
9
+ /** Default cloud system agent model. */
10
+ export const CLOUD_SYSTEM_MODEL = COORDINATOR_MODEL;
11
+
12
+ export interface CloudIntegrationsConfig {
13
+ baseUrl: string;
14
+ token: string;
15
+ }
16
+
17
+ export function getCloudIntegrationsConfig(): CloudIntegrationsConfig | null {
18
+ if (!isCloudMode()) return null;
19
+
20
+ const baseUrl = process.env.OPENBOT_INTEGRATIONS_BASE_URL?.trim();
21
+ const token = process.env.OPENBOT_INTEGRATIONS_TOKEN?.trim();
22
+ if (!baseUrl || !token) return null;
23
+
24
+ return { baseUrl: baseUrl.replace(/\/$/, ''), token };
25
+ }
26
+
27
+ export function isCloudSystemAgent(agentId: string): boolean {
28
+ return isCloudMode() && agentId === ORCHESTRATOR_AGENT_ID;
29
+ }
30
+
31
+ export function resolveCloudSystemModel(configModel: string | undefined): string {
32
+ return configModel?.trim() || COORDINATOR_MODEL;
33
+ }
34
+
35
+ export function assertCloudSystemAgentPluginsMutable(
36
+ agentId: string,
37
+ plugins: unknown,
38
+ ): void {
39
+ void agentId;
40
+ void plugins;
41
+ }
package/src/app/config.ts CHANGED
@@ -8,6 +8,8 @@ export interface OpenBotconfig {
8
8
  model?: string;
9
9
  image?: string;
10
10
  baseDir?: string;
11
+ /** Parent directory for per-channel working directories (user-facing workspace). */
12
+ channelsWorkspaceDir?: string;
11
13
  port?: number;
12
14
  /**
13
15
  * Overrides the default public marketplace registry URL. If omitted or blank,
@@ -41,52 +43,102 @@ export function resolvePath(p: string) {
41
43
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(p);
42
44
  }
43
45
 
46
+ function readConfigFile(configDir: string): OpenBotconfig {
47
+ const configPath = path.join(configDir, CONFIG_FILE);
48
+ if (fs.existsSync(configPath)) {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as OpenBotconfig;
51
+ } catch (error) {
52
+ console.error(`Warning: Failed to parse config at ${configPath}`, error);
53
+ }
54
+ }
55
+ return {};
56
+ }
57
+
58
+ /** Directory that holds `config.json` (env `OPENBOT_BASE_DIR` or `~/.openbot`). */
59
+ export function getConfigDir(): string {
60
+ const envBase = process.env.OPENBOT_BASE_DIR?.trim();
61
+ if (envBase) {
62
+ return resolvePath(envBase);
63
+ }
64
+ return path.join(os.homedir(), '.openbot');
65
+ }
66
+
67
+ /** Resolved OpenBot data root (agents, channels, plugins, variables). */
68
+ export function getBaseDir(): string {
69
+ const envBase = process.env.OPENBOT_BASE_DIR?.trim();
70
+ if (envBase) {
71
+ return resolvePath(envBase);
72
+ }
73
+ const fileConfig = readConfigFile(getConfigDir());
74
+ return resolvePath(fileConfig.baseDir || DEFAULT_BASE_DIR);
75
+ }
76
+
77
+ /** Resolved parent directory for per-channel workspace folders. */
78
+ export function getChannelsWorkspaceRoot(): string {
79
+ const env = process.env.OPENBOT_CHANNELS_WORKSPACE_DIR?.trim();
80
+ if (env) {
81
+ return resolvePath(env);
82
+ }
83
+ const fileConfig = readConfigFile(getConfigDir());
84
+ if (fileConfig.channelsWorkspaceDir) {
85
+ return resolvePath(fileConfig.channelsWorkspaceDir);
86
+ }
87
+ return resolvePath(DEFAULT_CHANNELS_WORKSPACE_DIR);
88
+ }
89
+
44
90
  /** Default absolute cwd for a channel when none is provided at creation time. */
45
91
  export function getDefaultChannelCwd(channelId: string): string {
46
92
  const id = channelId.trim();
47
93
  if (!id) {
48
94
  throw new Error('channelId is required');
49
95
  }
50
- return resolvePath(`${DEFAULT_CHANNELS_WORKSPACE_DIR}/${id}`);
96
+ return path.join(getChannelsWorkspaceRoot(), id);
51
97
  }
52
98
 
53
99
  export function loadConfig(): OpenBotconfig {
54
- const configPath = path.join(os.homedir(), '.openbot', CONFIG_FILE);
55
- if (fs.existsSync(configPath)) {
56
- try {
57
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
58
- } catch (error) {
59
- console.error(`Warning: Failed to parse config at ${configPath}`, error);
60
- }
100
+ const fileConfig = readConfigFile(getConfigDir());
101
+ const merged: OpenBotconfig = { ...fileConfig };
102
+
103
+ merged.baseDir = getBaseDir();
104
+
105
+ const envPublicUrl = process.env.OPENBOT_PUBLIC_URL?.trim();
106
+ if (envPublicUrl) {
107
+ merged.publicUrl = envPublicUrl.replace(/\/$/, '');
108
+ } else if (merged.publicUrl) {
109
+ merged.publicUrl = merged.publicUrl.replace(/\/$/, '');
61
110
  }
62
- return {};
111
+
112
+ merged.channelsWorkspaceDir = getChannelsWorkspaceRoot();
113
+
114
+ const envPort = process.env.PORT?.trim();
115
+ if (envPort && !Number.isNaN(Number(envPort))) {
116
+ merged.port = Number(envPort);
117
+ }
118
+
119
+ return merged;
63
120
  }
64
121
 
65
122
  export function saveConfig(config: Partial<OpenBotconfig>) {
66
- const configDir = resolvePath(DEFAULT_BASE_DIR);
123
+ const configDir = getConfigDir();
67
124
  const configPath = path.join(configDir, CONFIG_FILE);
68
125
 
69
126
  if (!fs.existsSync(configDir)) {
70
127
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
71
128
  }
72
129
 
73
- const currentConfig = loadConfig();
130
+ const currentConfig = readConfigFile(configDir);
74
131
  const newConfig = { ...currentConfig, ...config };
75
132
 
76
133
  fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), { mode: 0o600 });
77
134
  }
78
135
 
79
136
  export function isConfigured(): boolean {
80
- const configPath = path.join(resolvePath(DEFAULT_BASE_DIR), CONFIG_FILE);
81
- return fs.existsSync(configPath);
137
+ return fs.existsSync(path.join(getConfigDir(), CONFIG_FILE));
82
138
  }
83
139
 
84
140
  export function loadVariables(): { version: number; variables: StoredVariable[] } {
85
- const config = loadConfig();
86
- const variablesPath = path.join(
87
- resolvePath(config.baseDir || DEFAULT_BASE_DIR),
88
- VARIABLES_FILE,
89
- );
141
+ const variablesPath = path.join(getBaseDir(), VARIABLES_FILE);
90
142
  if (fs.existsSync(variablesPath)) {
91
143
  return JSON.parse(fs.readFileSync(variablesPath, 'utf-8')) as {
92
144
  version: number;
@@ -94,4 +146,4 @@ export function loadVariables(): { version: number; variables: StoredVariable[]
94
146
  };
95
147
  }
96
148
  return { version: 1, variables: [] };
97
- }
149
+ }