openbot 0.3.6 → 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 (104) hide show
  1. package/README.md +15 -16
  2. package/dist/app/agent-ids.js +4 -0
  3. package/dist/app/cli.js +1 -1
  4. package/dist/app/config.js +10 -19
  5. package/dist/app/server.js +208 -17
  6. package/dist/bus/services.js +34 -124
  7. package/dist/harness/agent-invoke-run.js +44 -0
  8. package/dist/harness/agent-turn.js +99 -0
  9. package/dist/harness/channel-participants.js +40 -0
  10. package/dist/harness/constants.js +2 -0
  11. package/dist/harness/context-meter.js +97 -0
  12. package/dist/harness/context.js +95 -47
  13. package/dist/harness/dispatch.js +144 -0
  14. package/dist/harness/dispatcher.js +45 -156
  15. package/dist/harness/history.js +177 -0
  16. package/dist/harness/index.js +109 -0
  17. package/dist/harness/orchestration.js +88 -0
  18. package/dist/harness/participants.js +22 -0
  19. package/dist/harness/run-harness.js +154 -0
  20. package/dist/harness/run.js +98 -0
  21. package/dist/harness/runtime-factory.js +0 -34
  22. package/dist/harness/runtime.js +57 -0
  23. package/dist/harness/todo-dispatch.js +51 -0
  24. package/dist/harness/todos.js +5 -0
  25. package/dist/harness/turn.js +79 -0
  26. package/dist/plugins/approval/index.js +120 -149
  27. package/dist/plugins/bash/index.js +195 -0
  28. package/dist/plugins/delegation/index.js +121 -32
  29. package/dist/plugins/memory/index.js +103 -14
  30. package/dist/plugins/memory/service.js +152 -0
  31. package/dist/plugins/openbot/context.js +125 -0
  32. package/dist/plugins/openbot/history.js +144 -0
  33. package/dist/plugins/openbot/index.js +71 -0
  34. package/dist/plugins/openbot/runtime.js +381 -0
  35. package/dist/plugins/openbot/system-prompt.js +25 -0
  36. package/dist/plugins/plugin-manager/index.js +189 -0
  37. package/dist/plugins/shell/index.js +2 -1
  38. package/dist/plugins/storage/files.js +67 -0
  39. package/dist/plugins/storage/index.js +750 -0
  40. package/dist/plugins/storage/service.js +1316 -0
  41. package/dist/plugins/storage-tools/index.js +2 -2
  42. package/dist/plugins/thread-namer/index.js +72 -0
  43. package/dist/plugins/thread-naming/generate-title.js +44 -0
  44. package/dist/plugins/thread-naming/index.js +103 -0
  45. package/dist/plugins/threads/index.js +114 -0
  46. package/dist/plugins/todo/index.js +24 -25
  47. package/dist/plugins/ui/index.js +109 -180
  48. package/dist/registry/plugins.js +3 -9
  49. package/dist/services/abort.js +43 -0
  50. package/dist/services/plugins/domain.js +1 -0
  51. package/dist/services/plugins/plugin-cache.js +9 -0
  52. package/dist/services/plugins/registry.js +112 -0
  53. package/dist/services/plugins/service.js +232 -0
  54. package/dist/services/plugins/types.js +1 -0
  55. package/dist/services/process.js +29 -0
  56. package/dist/services/storage.js +11 -10
  57. package/dist/services/thread-naming.js +81 -0
  58. package/docs/agents.md +15 -12
  59. package/docs/architecture.md +2 -2
  60. package/docs/plugins.md +29 -17
  61. package/docs/templates/AGENT.example.md +8 -14
  62. package/package.json +1 -2
  63. package/src/app/agent-ids.ts +5 -0
  64. package/src/app/cli.ts +1 -1
  65. package/src/app/config.ts +14 -31
  66. package/src/app/server.ts +243 -19
  67. package/src/app/types.ts +331 -187
  68. package/src/harness/index.ts +166 -0
  69. package/src/plugins/approval/index.ts +107 -188
  70. package/src/plugins/bash/index.ts +232 -0
  71. package/src/plugins/delegation/index.ts +139 -39
  72. package/src/plugins/memory/index.ts +112 -15
  73. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  74. package/src/plugins/openbot/context.ts +140 -0
  75. package/src/plugins/openbot/history.ts +158 -0
  76. package/src/plugins/openbot/index.ts +79 -0
  77. package/src/plugins/openbot/runtime.ts +478 -0
  78. package/src/plugins/openbot/system-prompt.ts +27 -0
  79. package/src/plugins/plugin-manager/index.ts +224 -0
  80. package/src/plugins/storage/files.ts +81 -0
  81. package/src/plugins/storage/index.ts +823 -0
  82. package/src/{services/storage.ts → plugins/storage/service.ts} +485 -105
  83. package/src/plugins/ui/index.ts +117 -221
  84. package/src/services/abort.ts +46 -0
  85. package/src/{bus/types.ts → services/plugins/domain.ts} +50 -8
  86. package/src/services/plugins/plugin-cache.ts +13 -0
  87. package/src/{registry/plugins.ts → services/plugins/registry.ts} +28 -28
  88. package/src/services/plugins/service.ts +318 -0
  89. package/src/{bus/plugin.ts → services/plugins/types.ts} +7 -3
  90. package/src/bus/services.ts +0 -954
  91. package/src/harness/context.ts +0 -365
  92. package/src/harness/dispatcher.ts +0 -379
  93. package/src/harness/mcp.ts +0 -78
  94. package/src/harness/runtime-factory.ts +0 -129
  95. package/src/harness/todo-advance.ts +0 -128
  96. package/src/plugins/ai-sdk/index.ts +0 -41
  97. package/src/plugins/ai-sdk/runtime.ts +0 -468
  98. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  99. package/src/plugins/mcp/index.ts +0 -128
  100. package/src/plugins/shell/index.ts +0 -123
  101. package/src/plugins/storage-tools/index.ts +0 -90
  102. package/src/plugins/todo/index.ts +0 -64
  103. package/src/services/plugins.ts +0 -133
  104. /package/src/{harness → services}/process.ts +0 -0
@@ -1,13 +1,15 @@
1
+ import { ORCHESTRATOR_AGENT_ID, STATE_AGENT_ID } from '../../app/agent-ids.js';
1
2
  import {
2
3
  DEFAULT_PLUGINS_DIR,
3
4
  DEFAULT_AGENTS_DIR,
4
5
  DEFAULT_BASE_DIR,
5
6
  DEFAULT_CHANNELS_DIR,
7
+ getDefaultChannelCwd,
6
8
  loadConfig,
7
9
  resolvePath,
8
10
  StoredVariable,
9
11
  VARIABLES_FILE,
10
- } from '../app/config.js';
12
+ } from '../../app/config.js';
11
13
  import fs from 'node:fs/promises';
12
14
  import { readFileSync } from 'node:fs';
13
15
  import path from 'node:path';
@@ -22,14 +24,18 @@ import {
22
24
  PluginDescriptor,
23
25
  Thread,
24
26
  ThreadDetails,
25
- } from '../bus/types.js';
26
- import type { PluginRef } from '../bus/plugin.js';
27
- import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
28
- import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
29
- import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
30
- import { OpenBotEvent, OpenBotState } from '../app/types.js';
31
- import { processService } from '../harness/process.js';
32
- import { memoryService } from './memory.js';
27
+ } from '../../services/plugins/domain.js';
28
+ import type { PluginRef } from '../../services/plugins/types.js';
29
+ import { openbotPlugin } from '../openbot/index.js';
30
+ import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
31
+ import { OpenBotEvent, OpenBotState } from '../../app/types.js';
32
+ import { processService } from '../../services/process.js';
33
+ import { memoryService } from '../memory/service.js';
34
+ import {
35
+ guessMimeType,
36
+ resolveChannelFile,
37
+ statChannelFile,
38
+ } from './files.js';
33
39
 
34
40
  const resolveBaseDir = () => {
35
41
  const config = loadConfig();
@@ -49,7 +55,10 @@ function getBundledSystemAgentImage(): string | undefined {
49
55
  if (bundledSystemAgentImageLoaded) return bundledSystemAgentImage;
50
56
  bundledSystemAgentImageLoaded = true;
51
57
  try {
52
- const iconPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../assets/icon.svg');
58
+ const iconPath = path.join(
59
+ path.dirname(fileURLToPath(import.meta.url)),
60
+ '../../assets/icon.svg',
61
+ );
53
62
  const trimmed = readFileSync(iconPath, 'utf-8').trim();
54
63
  if (!trimmed.startsWith('<svg')) return undefined;
55
64
  bundledSystemAgentImage = toSvgDataUrl(trimmed);
@@ -103,27 +112,37 @@ const getConversationDir = (channelId: string, threadId?: string) => {
103
112
  };
104
113
 
105
114
  /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
106
- const SYSTEM_AGENT_ID = 'system';
115
+ const SYSTEM_AGENT_ID = ORCHESTRATOR_AGENT_ID;
107
116
 
108
117
  const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
109
- { id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
110
- { id: 'storage-tools' },
111
- // { id: 'mcp' },
112
- { id: 'shell' },
113
- { id: 'todo' },
114
- // { id: 'ui' },
115
- { id: 'approval' },
116
- { id: 'memory' },
118
+ {
119
+ id: 'openbot',
120
+ config: {
121
+ model: 'openai/gpt-5.4-mini',
122
+ approval: {
123
+ actions: ['action:bash', 'action:create_channel', 'action:delete_channel'],
124
+ },
125
+ },
126
+ },
117
127
  ];
118
128
 
129
+ /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
130
+ const STATE_DEFAULT_PLUGINS: PluginRef[] = [
131
+ { id: 'storage' },
132
+ { id: 'plugin-manager' },
133
+ ];
134
+
135
+ const STATE_AGENT_INSTRUCTIONS =
136
+ 'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
137
+
119
138
  function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
120
139
  const defaults: AgentDetails = {
121
140
  id: SYSTEM_AGENT_ID,
122
141
  name: 'OpenBot',
123
142
  image: getBundledSystemAgentImage(),
124
143
  description:
125
- 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff.',
126
- instructions: AI_SDK_SYSTEM_PROMPT,
144
+ 'First-party orchestration agent for OpenBot.',
145
+ instructions: '',
127
146
  plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
128
147
  pluginRefs: SYSTEM_DEFAULT_PLUGINS,
129
148
  createdAt: new Date(),
@@ -136,10 +155,15 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
136
155
  ? overrides.pluginRefs
137
156
  : defaults.pluginRefs;
138
157
 
158
+ const diskInstructions = overrides.instructions?.trim();
159
+ const instructions =
160
+ diskInstructions && diskInstructions.length > 0 ? diskInstructions : defaults.instructions;
161
+
139
162
  return {
140
163
  ...defaults,
141
164
  ...overrides,
142
165
  id: SYSTEM_AGENT_ID,
166
+ instructions,
143
167
  image: overrides.image || defaults.image,
144
168
  plugins: refs.map((ref) => ref.id),
145
169
  pluginRefs: refs,
@@ -147,18 +171,65 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
147
171
  };
148
172
  }
149
173
 
150
- // Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
151
- void aiSdkPlugin;
174
+ function getStateAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
175
+ const defaults: AgentDetails = {
176
+ id: STATE_AGENT_ID,
177
+ name: 'State',
178
+ image: getBundledSystemAgentImage(),
179
+ description: 'Infrastructure agent for OpenBot — storage and hooks without an LLM.',
180
+ instructions: STATE_AGENT_INSTRUCTIONS,
181
+ plugins: STATE_DEFAULT_PLUGINS.map((ref) => ref.id),
182
+ pluginRefs: STATE_DEFAULT_PLUGINS,
183
+ hidden: true,
184
+ createdAt: new Date(),
185
+ updatedAt: new Date(),
186
+ };
187
+
188
+ if (!overrides) return defaults;
189
+
190
+ const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
191
+ ? overrides.pluginRefs
192
+ : defaults.pluginRefs;
193
+
194
+ const diskInstructions = overrides.instructions?.trim();
195
+ const instructions =
196
+ diskInstructions && diskInstructions.length > 0 ? diskInstructions : defaults.instructions;
152
197
 
153
- const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
198
+ return {
199
+ ...defaults,
200
+ ...overrides,
201
+ id: STATE_AGENT_ID,
202
+ instructions,
203
+ image: overrides.image || defaults.image,
204
+ hidden: overrides.hidden !== undefined ? overrides.hidden : defaults.hidden,
205
+ plugins: refs.map((ref) => ref.id),
206
+ pluginRefs: refs,
207
+ updatedAt: new Date(),
208
+ };
209
+ }
154
210
 
155
- const assertValidDiskAgentId = (agentId: string): void => {
211
+ const agentSummaryFromDetails = (details: AgentDetails): Agent => ({
212
+ id: details.id,
213
+ name: details.name || details.id,
214
+ description: details.description || '',
215
+ image: details.image,
216
+ plugins: details.plugins,
217
+ hidden: details.hidden,
218
+ createdAt: details.createdAt,
219
+ updatedAt: details.updatedAt,
220
+ });
221
+
222
+ // Suppress unused warning until system agent customization re-uses openbotPlugin metadata.
223
+ void openbotPlugin;
224
+
225
+ /** Built-in agents may persist optional `agents/<id>/AGENT.md` overlays; read path merges them with defaults. */
226
+ const isBuiltinOverlayAgentId = (agentId: string): boolean =>
227
+ agentId === SYSTEM_AGENT_ID || agentId === STATE_AGENT_ID;
228
+
229
+ const assertAgentIdFormat = (agentId: string): void => {
156
230
  if (!agentId || typeof agentId !== 'string') {
157
231
  throw new Error('agentId is required');
158
232
  }
159
- if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
160
- throw new Error(`Agent id "${agentId}" is reserved`);
161
- }
162
233
  if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
163
234
  throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
164
235
  }
@@ -169,14 +240,31 @@ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
169
240
  const getLastReadFilePath = () =>
170
241
  path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
171
242
 
243
+ /** Sentinel key for channel-root events (no threadId). */
244
+ export const ROOT_THREAD_KEY = '__root__';
245
+
246
+ export type LastReadMap = Record<string, Record<string, string>>;
247
+
248
+ const readLastReadMap = async (): Promise<LastReadMap> => {
249
+ const raw = await readJsonFile<Record<string, any>>(getLastReadFilePath(), {});
250
+ const map: LastReadMap = {};
251
+ for (const [channelId, value] of Object.entries(raw)) {
252
+ if (typeof value === 'string') {
253
+ // Migrate old format
254
+ map[channelId] = { [ROOT_THREAD_KEY]: value };
255
+ } else if (value && typeof value === 'object') {
256
+ map[channelId] = value as Record<string, string>;
257
+ }
258
+ }
259
+ return map;
260
+ };
261
+
172
262
  const THREAD_TITLE_MAX_LENGTH = 80;
173
263
 
174
264
  const buildThreadTitleFromEvent = (event: OpenBotEvent): string | undefined => {
175
265
  let rawContent = '';
176
266
 
177
- if (event.type === 'user:input' && typeof event.data?.content === 'string') {
178
- rawContent = event.data.content;
179
- } else if (
267
+ if (
180
268
  event.type === 'agent:invoke' &&
181
269
  event.data?.role === 'user' &&
182
270
  typeof event.data.content === 'string'
@@ -196,9 +284,12 @@ const buildThreadTitleFromEvent = (event: OpenBotEvent): string | undefined => {
196
284
 
197
285
  const readJsonFile = async <T>(filePath: string, fallback: T): Promise<T> => {
198
286
  try {
199
- return JSON.parse(await fs.readFile(filePath, 'utf-8')) as T;
287
+ const content = (await fs.readFile(filePath, 'utf-8')).trim();
288
+ if (!content) return fallback;
289
+ return JSON.parse(content) as T;
200
290
  } catch (e: unknown) {
201
291
  if ((e as { code?: string })?.code === 'ENOENT') return fallback;
292
+ if (e instanceof SyntaxError) return fallback;
202
293
  throw e;
203
294
  }
204
295
  };
@@ -339,6 +430,12 @@ const readChannelStateFileFields = (
339
430
  * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
340
431
  * `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
341
432
  */
433
+ const parseHiddenFlag = (raw: unknown): boolean | undefined => {
434
+ if (raw === true) return true;
435
+ if (raw === false) return false;
436
+ return undefined;
437
+ };
438
+
342
439
  const parsePluginRefs = (raw: unknown): PluginRef[] => {
343
440
  if (!Array.isArray(raw)) return [];
344
441
  const refs: PluginRef[] = [];
@@ -359,22 +456,27 @@ const serializePluginRefs = (refs: PluginRef[]): unknown[] =>
359
456
  refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
360
457
 
361
458
  export const storageService = {
362
- getLastReadByChannel: async (): Promise<Record<string, string>> => {
363
- return readJsonFile(getLastReadFilePath(), {});
459
+ getLastReadMap: async (): Promise<LastReadMap> => {
460
+ return readLastReadMap();
364
461
  },
365
462
 
366
- setLastReadForChannel: async ({
463
+ setLastRead: async ({
367
464
  channelId,
465
+ threadId,
368
466
  lastReadEventId,
369
467
  }: {
370
468
  channelId: string;
469
+ threadId?: string;
371
470
  lastReadEventId: string;
372
471
  }): Promise<void> => {
373
472
  const p = getLastReadFilePath();
374
473
  await fs.mkdir(path.dirname(p), { recursive: true });
375
- const map = await readJsonFile<Record<string, string>>(p, {});
376
- map[channelId] = lastReadEventId;
377
- await fs.writeFile(p, JSON.stringify(map, null, 2), 'utf-8');
474
+ const map = await readLastReadMap();
475
+ if (!map[channelId]) map[channelId] = {};
476
+ map[channelId][threadId || ROOT_THREAD_KEY] = lastReadEventId;
477
+ const tmp = `${p}.tmp`;
478
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
479
+ await fs.rename(tmp, p);
378
480
  },
379
481
 
380
482
  getChannels: async (): Promise<Channel[]> => {
@@ -388,11 +490,12 @@ export const storageService = {
388
490
  const channelNames = (await fs.readdir(channelsDir)).filter(
389
491
  (name) => !name.startsWith('.') && name !== '_meta',
390
492
  );
391
- const lastReadByChannel = await storageService.getLastReadByChannel();
493
+ const lastReadMap = await storageService.getLastReadMap();
392
494
 
393
495
  const channels = await Promise.all(
394
496
  channelNames.map(async (name) => {
395
497
  const channelDir = getConversationDir(name);
498
+ const stats = await fs.stat(channelDir);
396
499
  const statePath = path.join(channelDir, 'state.json');
397
500
  let cwd: string | undefined;
398
501
  let displayName = name;
@@ -415,24 +518,28 @@ export const storageService = {
415
518
  description: '',
416
519
  cwd,
417
520
  participants,
418
- createdAt: new Date(),
419
- updatedAt: new Date(),
521
+ createdAt: stats.birthtime,
522
+ updatedAt: stats.mtime,
420
523
  };
421
- const rid = lastReadByChannel[name];
422
- try {
423
- const events = await storageService.getEvents({ channelId: name });
424
- const latestId = events[events.length - 1]?.id;
425
- channel.hasUnseenMessages = !!(latestId && latestId !== rid);
426
- } catch {
427
- channel.hasUnseenMessages = false;
428
- }
429
524
 
525
+ const channelLastRead = lastReadMap[name] || {};
526
+
430
527
  try {
528
+ // Check root unread
529
+ const rootEvents = await storageService.getEvents({ channelId: name });
530
+ const rootLatestId = rootEvents[rootEvents.length - 1]?.id;
531
+ const rootUnseen = !!(rootLatestId && rootLatestId !== channelLastRead[ROOT_THREAD_KEY]);
532
+
533
+ // Check threads unread
431
534
  const allThreads = await storageService.getThreads({ channelId: name });
432
535
  channel.recentThreads = allThreads
433
536
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
434
537
  .slice(0, 5);
538
+
539
+ const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
540
+ channel.hasUnseenMessages = rootUnseen || threadsUnseen;
435
541
  } catch {
542
+ channel.hasUnseenMessages = false;
436
543
  channel.recentThreads = [];
437
544
  }
438
545
 
@@ -440,7 +547,7 @@ export const storageService = {
440
547
  }),
441
548
  );
442
549
 
443
- return channels;
550
+ return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
444
551
  },
445
552
  createChannel: async ({
446
553
  channelId,
@@ -472,22 +579,63 @@ export const storageService = {
472
579
  }
473
580
  }
474
581
 
475
- const finalState = {
582
+ const finalState: Record<string, unknown> = {
476
583
  ...(initialState || {}),
477
584
  };
478
585
 
479
- if (cwd) {
480
- (finalState as Record<string, unknown>).cwd = cwd;
481
- }
586
+ const rawCwd =
587
+ (typeof cwd === 'string' && cwd.trim()) ||
588
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
589
+ getDefaultChannelCwd(normalizedChannelId);
482
590
 
591
+ const resolvedCwd = resolvePath(rawCwd);
592
+ finalState.cwd = resolvedCwd;
593
+ await fs.mkdir(resolvedCwd, { recursive: true });
483
594
  await fs.mkdir(channelDir, { recursive: true });
484
595
  await fs.writeFile(
485
596
  specPath,
486
597
  spec?.trim() ||
487
- `# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`,
598
+ `# ${normalizedChannelId}\n\n`,
488
599
  );
489
600
  await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
490
601
  },
602
+ deleteChannel: async ({ channelId }: { channelId: string }): Promise<void> => {
603
+ const normalizedChannelId = channelId.trim();
604
+ if (!normalizedChannelId) {
605
+ throw new Error('channelId is required');
606
+ }
607
+ if (normalizedChannelId === '_meta') {
608
+ throw new Error('Cannot delete reserved channel path');
609
+ }
610
+
611
+ const channelDir = getConversationDir(normalizedChannelId);
612
+ try {
613
+ await fs.access(channelDir);
614
+ } catch (error: unknown) {
615
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
616
+ const err = new Error(`Channel "${normalizedChannelId}" does not exist.`);
617
+ (err as Error & { code?: string }).code = 'CHANNEL_NOT_FOUND';
618
+ throw err;
619
+ }
620
+ throw error;
621
+ }
622
+
623
+ await fs.rm(channelDir, { recursive: true, force: true });
624
+
625
+ try {
626
+ const lastReadPath = getLastReadFilePath();
627
+ const map = await readJsonFile<Record<string, string>>(lastReadPath, {});
628
+ if (normalizedChannelId in map) {
629
+ delete map[normalizedChannelId];
630
+ await fs.mkdir(path.dirname(lastReadPath), { recursive: true });
631
+ const tmp = `${lastReadPath}.tmp`;
632
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
633
+ await fs.rename(tmp, lastReadPath);
634
+ }
635
+ } catch {
636
+ // ignore last-read cleanup failures
637
+ }
638
+ },
491
639
  createThread: async ({
492
640
  channelId,
493
641
  threadId,
@@ -522,7 +670,7 @@ export const storageService = {
522
670
 
523
671
  const baseState: Record<string, unknown> = { ...(initialState || {}) };
524
672
  if (threadTitle?.trim()) {
525
- baseState.generatedName = threadTitle.trim();
673
+ baseState.name = threadTitle.trim();
526
674
  }
527
675
 
528
676
  await fs.mkdir(threadDir, { recursive: true });
@@ -538,6 +686,8 @@ export const storageService = {
538
686
  return [];
539
687
  }
540
688
 
689
+ const lastReadMap = await storageService.getLastReadMap();
690
+ const channelLastRead = lastReadMap[channelId] || {};
541
691
  const threadNames = (await fs.readdir(threadsDir)).filter((name) => !name.startsWith('.'));
542
692
 
543
693
  const threads = await Promise.all(
@@ -550,10 +700,10 @@ export const storageService = {
550
700
  try {
551
701
  const threadStateRaw = await fs.readFile(threadStatePath, 'utf-8');
552
702
  const threadState = JSON.parse(threadStateRaw) as Record<string, unknown>;
553
- const generatedName =
554
- typeof threadState.generatedName === 'string' ? threadState.generatedName.trim() : '';
555
- if (generatedName) {
556
- threadDisplayName = generatedName;
703
+ const threadName =
704
+ typeof threadState.name === 'string' ? threadState.name.trim() : '';
705
+ if (threadName) {
706
+ threadDisplayName = threadName;
557
707
  }
558
708
  } catch (error: unknown) {
559
709
  if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
@@ -564,12 +714,22 @@ export const storageService = {
564
714
  }
565
715
  }
566
716
 
717
+ let hasUnseen = false;
718
+ try {
719
+ const events = await storageService.getEvents({ channelId, threadId: name });
720
+ const latestId = events[events.length - 1]?.id;
721
+ hasUnseen = !!(latestId && latestId !== channelLastRead[name]);
722
+ } catch {
723
+ // ignore
724
+ }
725
+
567
726
  return {
568
727
  id: name,
569
728
  name: threadDisplayName,
570
729
  channelId,
571
730
  createdAt: stats.birthtime,
572
731
  updatedAt: stats.mtime,
732
+ hasUnseenMessages: hasUnseen,
573
733
  };
574
734
  }),
575
735
  );
@@ -599,14 +759,14 @@ export const storageService = {
599
759
  }
600
760
  }
601
761
 
602
- const generatedName =
603
- isRecord(state) && typeof state.generatedName === 'string'
604
- ? state.generatedName.trim()
762
+ const threadName =
763
+ isRecord(state) && typeof state.name === 'string'
764
+ ? state.name.trim()
605
765
  : '';
606
766
 
607
767
  return {
608
768
  id: threadId,
609
- name: generatedName || threadId,
769
+ name: threadName || threadId,
610
770
  channelId,
611
771
  state,
612
772
  };
@@ -741,15 +901,7 @@ export const storageService = {
741
901
  agentIds.map(async (id) => {
742
902
  try {
743
903
  const details = await storageService.getAgentDetails({ agentId: id });
744
- return {
745
- id,
746
- name: details.name || id,
747
- description: details.description || '',
748
- image: details.image,
749
- plugins: details.plugins,
750
- createdAt: details.createdAt,
751
- updatedAt: details.updatedAt,
752
- } satisfies Agent;
904
+ return agentSummaryFromDetails(details);
753
905
  } catch {
754
906
  return {
755
907
  id,
@@ -764,23 +916,19 @@ export const storageService = {
764
916
  );
765
917
 
766
918
  const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
767
- const builtInSystemAgent: Agent = {
768
- id: system.id,
769
- name: system.name,
770
- description: system.description || '',
771
- image: system.image,
772
- plugins: system.plugins,
773
- createdAt: system.createdAt,
774
- updatedAt: system.updatedAt,
775
- };
919
+ const builtInSystemAgent = agentSummaryFromDetails(system);
920
+
921
+ const builtInStateRow = await storageService.getAgentDetails({ agentId: STATE_AGENT_ID });
922
+ const builtInStateAgent = agentSummaryFromDetails(builtInStateRow);
776
923
 
777
924
  const deduped = new Map<string, Agent>();
778
925
  deduped.set(builtInSystemAgent.id, builtInSystemAgent);
926
+ deduped.set(builtInStateAgent.id, builtInStateAgent);
779
927
  for (const agent of agents) {
780
928
  if (!deduped.has(agent.id)) deduped.set(agent.id, agent);
781
929
  }
782
930
 
783
- return Array.from(deduped.values());
931
+ return Array.from(deduped.values()).filter((agent) => !agent.hidden);
784
932
  },
785
933
  getPlugins: async (): Promise<PluginDescriptor[]> => {
786
934
  const [builtIn, fromDisk] = await Promise.all([
@@ -824,16 +972,17 @@ export const storageService = {
824
972
  pluginRefs,
825
973
  description: typeof data.description === 'string' ? data.description : '',
826
974
  image: frontmatterImage || discoveredImage || undefined,
975
+ hidden: parseHiddenFlag(data.hidden),
827
976
  createdAt: stats.birthtime,
828
977
  updatedAt: stats.mtime,
829
978
  };
830
979
  } catch (error) {
831
- if (agentId !== SYSTEM_AGENT_ID) {
980
+ if (agentId !== SYSTEM_AGENT_ID && agentId !== STATE_AGENT_ID) {
832
981
  const err = new Error(`Agent "${agentId}" does not exist.`);
833
982
  (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
834
983
  throw err;
835
984
  }
836
- // swallow: system agent has on-disk overrides optional
985
+ // swallow: built-in agents have optional `agents/<id>/AGENT.md` overrides
837
986
  void error;
838
987
  }
839
988
 
@@ -841,6 +990,10 @@ export const storageService = {
841
990
  return getSystemAgentDetails(diskDetails);
842
991
  }
843
992
 
993
+ if (agentId === STATE_AGENT_ID) {
994
+ return getStateAgentDetails(diskDetails);
995
+ }
996
+
844
997
  if (!diskDetails) {
845
998
  const error = new Error(`Agent "${agentId}" does not exist.`);
846
999
  (error as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
@@ -854,6 +1007,7 @@ export const storageService = {
854
1007
  name,
855
1008
  description = '',
856
1009
  image,
1010
+ hidden,
857
1011
  instructions,
858
1012
  plugins,
859
1013
  }: {
@@ -861,10 +1015,11 @@ export const storageService = {
861
1015
  name: string;
862
1016
  description?: string;
863
1017
  image?: string;
1018
+ hidden?: boolean;
864
1019
  instructions: string;
865
1020
  plugins: PluginRef[];
866
1021
  }): Promise<void> => {
867
- assertValidDiskAgentId(agentId);
1022
+ assertAgentIdFormat(agentId);
868
1023
  const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
869
1024
  const agentMdPath = path.join(agentDir, 'AGENT.md');
870
1025
 
@@ -892,6 +1047,9 @@ export const storageService = {
892
1047
  if (typeof image === 'string' && image.trim() !== '') {
893
1048
  data.image = image.trim();
894
1049
  }
1050
+ if (hidden === true) {
1051
+ data.hidden = true;
1052
+ }
895
1053
 
896
1054
  const body = matter.stringify(`${instructions.trim()}\n`, data);
897
1055
  await fs.writeFile(agentMdPath, body, 'utf-8');
@@ -901,6 +1059,7 @@ export const storageService = {
901
1059
  name,
902
1060
  description,
903
1061
  image,
1062
+ hidden,
904
1063
  instructions,
905
1064
  plugins,
906
1065
  }: {
@@ -908,10 +1067,11 @@ export const storageService = {
908
1067
  name?: string;
909
1068
  description?: string;
910
1069
  image?: string;
1070
+ hidden?: boolean;
911
1071
  instructions?: string;
912
1072
  plugins?: PluginRef[];
913
1073
  }): Promise<void> => {
914
- assertValidDiskAgentId(agentId);
1074
+ assertAgentIdFormat(agentId);
915
1075
  const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
916
1076
  const agentMdPath = path.join(agentDir, 'AGENT.md');
917
1077
 
@@ -920,14 +1080,18 @@ export const storageService = {
920
1080
  raw = await fs.readFile(agentMdPath, 'utf-8');
921
1081
  } catch (error: unknown) {
922
1082
  if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
923
- const err = new Error(`Agent "${agentId}" does not exist.`);
924
- (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
925
- throw err;
1083
+ if (!isBuiltinOverlayAgentId(agentId)) {
1084
+ const err = new Error(`Agent "${agentId}" does not exist.`);
1085
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
1086
+ throw err;
1087
+ }
1088
+ raw = '';
1089
+ } else {
1090
+ throw error;
926
1091
  }
927
- throw error;
928
1092
  }
929
1093
 
930
- const parsed = matter(raw);
1094
+ const parsed = raw === '' ? { data: {}, content: '' } : matter(raw);
931
1095
  const nextData: Record<string, unknown> = { ...parsed.data };
932
1096
  if (name !== undefined) nextData.name = name;
933
1097
  if (description !== undefined) nextData.description = description;
@@ -939,17 +1103,84 @@ export const storageService = {
939
1103
  delete nextData.image;
940
1104
  }
941
1105
  }
1106
+ if (hidden !== undefined) {
1107
+ if (hidden) {
1108
+ nextData.hidden = true;
1109
+ } else {
1110
+ delete nextData.hidden;
1111
+ }
1112
+ }
1113
+
1114
+ let nextContent = instructions !== undefined ? instructions : parsed.content;
1115
+
1116
+ // Built-in agents merge disk overlays with code defaults on read; on write, partial
1117
+ // updates (e.g. plugins-only) must still persist a complete AGENT.md.
1118
+ if (isBuiltinOverlayAgentId(agentId)) {
1119
+ const pluginRefs =
1120
+ plugins ??
1121
+ (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
1122
+ const diskOverrides: Partial<AgentDetails> = {};
1123
+ if (typeof nextData.name === 'string' && nextData.name.trim() !== '') {
1124
+ diskOverrides.name = nextData.name;
1125
+ }
1126
+ if (typeof nextData.description === 'string') {
1127
+ diskOverrides.description = nextData.description;
1128
+ }
1129
+ const trimmedContent = String(nextContent).trim();
1130
+ if (trimmedContent) {
1131
+ diskOverrides.instructions = trimmedContent;
1132
+ }
1133
+ if (pluginRefs && pluginRefs.length > 0) {
1134
+ diskOverrides.pluginRefs = pluginRefs;
1135
+ }
1136
+
1137
+ const effective =
1138
+ agentId === SYSTEM_AGENT_ID
1139
+ ? getSystemAgentDetails(diskOverrides)
1140
+ : getStateAgentDetails(diskOverrides);
1141
+
1142
+ if (name === undefined) nextData.name = effective.name;
1143
+ if (description === undefined) nextData.description = effective.description;
1144
+ if (instructions === undefined && !String(nextContent).trim()) {
1145
+ nextContent = effective.instructions;
1146
+ }
1147
+ }
942
1148
 
943
- const nextContent = instructions !== undefined ? instructions : parsed.content;
944
1149
  const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
1150
+ await fs.mkdir(path.dirname(agentMdPath), { recursive: true });
945
1151
  await fs.writeFile(agentMdPath, body, 'utf-8');
946
1152
  },
947
1153
  deleteAgent: async ({ agentId }: { agentId: string }): Promise<void> => {
948
- assertValidDiskAgentId(agentId);
1154
+ assertAgentIdFormat(agentId);
949
1155
  const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
950
1156
  const agentMdPath = path.join(agentDir, 'AGENT.md');
951
1157
  const packageJsonPath = path.join(agentDir, 'package.json');
952
1158
 
1159
+ if (isBuiltinOverlayAgentId(agentId)) {
1160
+ try {
1161
+ await fs.access(agentMdPath);
1162
+ } catch (error: unknown) {
1163
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
1164
+ const err = new Error(
1165
+ `Agent "${agentId}" has no AGENT.md on disk; nothing to remove (defaults already apply).`,
1166
+ );
1167
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
1168
+ throw err;
1169
+ }
1170
+ throw error;
1171
+ }
1172
+ await fs.unlink(agentMdPath);
1173
+ try {
1174
+ const remaining = await fs.readdir(agentDir);
1175
+ if (remaining.length === 0) {
1176
+ await fs.rmdir(agentDir);
1177
+ }
1178
+ } catch {
1179
+ // ignore cleanup failures
1180
+ }
1181
+ return;
1182
+ }
1183
+
953
1184
  try {
954
1185
  await fs.access(agentDir);
955
1186
  } catch (error: unknown) {
@@ -1060,8 +1291,10 @@ export const storageService = {
1060
1291
  try {
1061
1292
  const threadDir = getConversationDir(channelId, threadId);
1062
1293
  if (threadId) {
1294
+ let exists = false;
1063
1295
  try {
1064
1296
  await fs.access(threadDir);
1297
+ exists = true;
1065
1298
  } catch (error: unknown) {
1066
1299
  if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
1067
1300
  const threadTitle = buildThreadTitleFromEvent(event);
@@ -1074,6 +1307,23 @@ export const storageService = {
1074
1307
  throw error;
1075
1308
  }
1076
1309
  }
1310
+
1311
+ if (exists) {
1312
+ // If the thread already exists, check if it has a name.
1313
+ // This handles threads created via action:create_thread without a title.
1314
+ const threadDetails = await storageService.getThreadDetails({ channelId, threadId });
1315
+ const currentState = (threadDetails.state as Record<string, unknown>) || {};
1316
+ if (!currentState.name) {
1317
+ const threadTitle = buildThreadTitleFromEvent(event);
1318
+ if (threadTitle) {
1319
+ await storageService.patchThreadState({
1320
+ channelId,
1321
+ threadId,
1322
+ state: { name: threadTitle },
1323
+ });
1324
+ }
1325
+ }
1326
+ }
1077
1327
  } else {
1078
1328
  await fs.mkdir(threadDir, { recursive: true });
1079
1329
  }
@@ -1200,12 +1450,9 @@ export const storageService = {
1200
1450
  throw new Error('Channel has no CWD configured');
1201
1451
  }
1202
1452
 
1203
- const resolvedBase = path.resolve(baseCwd);
1204
- const targetDir = path.resolve(resolvedBase, subPath);
1205
-
1206
- if (!targetDir.startsWith(resolvedBase)) {
1207
- throw new Error('Access denied: directory escape');
1208
- }
1453
+ const targetDir = subPath
1454
+ ? resolveChannelFile(baseCwd, subPath)
1455
+ : resolvePath(baseCwd);
1209
1456
 
1210
1457
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1211
1458
  return entries
@@ -1230,14 +1477,142 @@ export const storageService = {
1230
1477
  throw new Error('Channel has no CWD configured');
1231
1478
  }
1232
1479
 
1233
- const resolvedBase = path.resolve(baseCwd);
1234
- const targetFile = path.resolve(resolvedBase, filePath);
1480
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1481
+ return fs.readFile(targetFile, 'utf-8');
1482
+ },
1235
1483
 
1236
- if (!targetFile.startsWith(resolvedBase)) {
1237
- throw new Error('Access denied: directory escape');
1484
+ readChannelFile: async ({
1485
+ channelId,
1486
+ path: filePath,
1487
+ encoding = 'utf8',
1488
+ }: {
1489
+ channelId: string;
1490
+ path: string;
1491
+ encoding?: 'utf8' | 'base64';
1492
+ }): Promise<{ content: string; mimeType: string; size: number }> => {
1493
+ const details = await storageService.getChannelDetails({ channelId });
1494
+ const baseCwd = details.cwd;
1495
+
1496
+ if (!baseCwd) {
1497
+ throw new Error('Channel has no CWD configured');
1238
1498
  }
1239
1499
 
1240
- return fs.readFile(targetFile, 'utf-8');
1500
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1501
+ const buf = await fs.readFile(targetFile);
1502
+ const content =
1503
+ encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1504
+
1505
+ return {
1506
+ content,
1507
+ mimeType: guessMimeType(targetFile),
1508
+ size: buf.length,
1509
+ };
1510
+ },
1511
+
1512
+ writeChannelFile: async ({
1513
+ channelId,
1514
+ path: filePath,
1515
+ content,
1516
+ encoding = 'utf8',
1517
+ overwrite = false,
1518
+ }: {
1519
+ channelId: string;
1520
+ path: string;
1521
+ content: string;
1522
+ encoding?: 'utf8' | 'base64';
1523
+ overwrite?: boolean;
1524
+ }): Promise<{ path: string; size: number; mimeType: string }> => {
1525
+ const details = await storageService.getChannelDetails({ channelId });
1526
+ const baseCwd = details.cwd;
1527
+
1528
+ if (!baseCwd) {
1529
+ throw new Error('Channel has no CWD configured');
1530
+ }
1531
+
1532
+ const abs = resolveChannelFile(baseCwd, filePath);
1533
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1534
+
1535
+ if (!overwrite) {
1536
+ try {
1537
+ await fs.access(abs);
1538
+ throw new Error('File already exists');
1539
+ } catch (error: unknown) {
1540
+ const code = (error as NodeJS.ErrnoException)?.code;
1541
+ if (code !== 'ENOENT') {
1542
+ throw error;
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ const buf =
1548
+ encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
1549
+ await fs.writeFile(abs, buf);
1550
+
1551
+ return {
1552
+ path: filePath,
1553
+ size: buf.length,
1554
+ mimeType: guessMimeType(abs),
1555
+ };
1556
+ },
1557
+
1558
+ uploadChannelFile: async ({
1559
+ channelId,
1560
+ path: filePath,
1561
+ body,
1562
+ overwrite = false,
1563
+ }: {
1564
+ channelId: string;
1565
+ path: string;
1566
+ body: Buffer;
1567
+ overwrite?: boolean;
1568
+ }): Promise<{ path: string; size: number; mimeType: string }> => {
1569
+ const details = await storageService.getChannelDetails({ channelId });
1570
+ const baseCwd = details.cwd;
1571
+
1572
+ if (!baseCwd) {
1573
+ throw new Error('Channel has no CWD configured');
1574
+ }
1575
+
1576
+ const abs = resolveChannelFile(baseCwd, filePath);
1577
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1578
+
1579
+ if (!overwrite) {
1580
+ try {
1581
+ await fs.access(abs);
1582
+ throw new Error('File already exists');
1583
+ } catch (error: unknown) {
1584
+ const code = (error as NodeJS.ErrnoException)?.code;
1585
+ if (code !== 'ENOENT') {
1586
+ throw error;
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ await fs.writeFile(abs, body);
1592
+
1593
+ return {
1594
+ path: filePath,
1595
+ size: body.length,
1596
+ mimeType: guessMimeType(abs),
1597
+ };
1598
+ },
1599
+
1600
+ getChannelFileStat: async ({
1601
+ channelId,
1602
+ path: filePath,
1603
+ }: {
1604
+ channelId: string;
1605
+ path: string;
1606
+ }): Promise<{ abs: string; size: number; mimeType: string }> => {
1607
+ const details = await storageService.getChannelDetails({ channelId });
1608
+ const baseCwd = details.cwd;
1609
+
1610
+ if (!baseCwd) {
1611
+ throw new Error('Channel has no CWD configured');
1612
+ }
1613
+
1614
+ const { abs, size } = await statChannelFile(baseCwd, filePath);
1615
+ return { abs, size, mimeType: guessMimeType(abs) };
1241
1616
  },
1242
1617
 
1243
1618
  appendMemory: memoryService.appendMemory,
@@ -1286,12 +1661,17 @@ export const storageService = {
1286
1661
  }
1287
1662
  }
1288
1663
 
1664
+ const threadState = (threadDetails?.state as Record<string, unknown>) || {};
1665
+
1289
1666
  return {
1290
1667
  runId,
1291
1668
  agentId,
1292
1669
  channelId,
1293
1670
  threadId,
1294
1671
  triggerEvent: event,
1672
+ pendingToolCallIds: Array.isArray(threadState.pendingToolCallIds)
1673
+ ? (threadState.pendingToolCallIds as string[])
1674
+ : undefined,
1295
1675
  agentDetails: {
1296
1676
  id: agentDetails.id,
1297
1677
  name: agentDetails.name,