agent-world 0.10.0 → 0.11.0

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.
package/README.md CHANGED
@@ -29,8 +29,9 @@ Paste that prompt. Agents come alive instantly.
29
29
  - ✅ No Code Required - Agents are defined entirely in natural language
30
30
  - ✅ Natural Communication - Agents understand context and conversations
31
31
  - ✅ Built-in Rules for Messages - Turn limits to prevent loops
32
+ - ✅ Progressive Agent Skills - Skills are discovered and loaded on demand
32
33
  - ✅ Multiple AI Providers - Use different models for different agents
33
- - ✅ Modern Web Interface - React + Next.js frontend with real-time chat
34
+ - ✅ Modern Web Interface - Clean, responsive UI with real-time chat
34
35
 
35
36
  ## What You Can Build
36
37
 
@@ -81,7 +82,7 @@ Each Agent World has a collection of agents that can communicate through a share
81
82
  | **Human message** | `Hello everyone!` | All active agents |
82
83
  | **Direct mention** | `@alice Can you help?` | Only @alice |
83
84
  | **Paragraph mention** | `Please review this:\n@alice` | Only @alice |
84
- | **Mid-text mention** | `I think @alice should help` | Nobody (saved to memory) |
85
+ | **Mid-text mention** | `I think @alice should help` | Nobody (event is persisted; no agent-memory save) |
85
86
  | **Stop World** | `<world>pass</world>` | No agents |
86
87
 
87
88
  ### Agent Behavior
@@ -93,8 +94,12 @@ Each Agent World has a collection of agents that can communicate through a share
93
94
 
94
95
  **Agents never respond to:**
95
96
  - Their own messages
96
- - Other agents (unless @mentioned), but will save message to memory
97
- - Mid-text mentions (will save message to memory)
97
+ - Other agents (unless @mentioned at paragraph start)
98
+ - Mid-text mentions (not at paragraph start)
99
+
100
+ **When messages are saved to agent memory:**
101
+ - Incoming messages are saved only for agents that will respond
102
+ - Non-responding agents skip agent-memory save (message events are still persisted)
98
103
 
99
104
  **Turn limits prevent loops:**
100
105
  - Default: 5 responses per conversation thread
@@ -133,42 +138,38 @@ echo "hi" | npx agent-world -w default-world
133
138
 
134
139
  See [Project Structure Documentation](project.md)
135
140
 
136
- ## Development Scripts Convention
141
+ ## Development Scripts
137
142
 
138
- Agent World follows a consistent naming convention for all npm scripts:
143
+ Agent World provides simple, consistent npm scripts for three main applications:
139
144
 
140
- | Script Pattern | Description | Example |
141
- |---------------|-------------|---------|
142
- | `<module>` | Shorthand for `<module>:start` | `npm run server` |
143
- | `<module>:start` | Run compiled code from `dist/` | `npm run server:start` |
144
- | `<module>:dev` | Run with tsx (no build needed) | `npm run server:dev` |
145
- | `<module>:watch` | Run with tsx in watch mode | `npm run server:watch` |
145
+ ### Development (hot reload)
146
+ ```bash
147
+ npm run dev # Web app with server (default)
148
+ npm run web:dev # Web app with server (explicit)
149
+ npm run cli:dev # CLI with watch mode
150
+ npm run electron:dev # Electron app
151
+ ```
146
152
 
147
- **Available modules:** `server`, `cli`, `ws`, `tui`
153
+ ### Production
154
+ ```bash
155
+ npm start # Web server (default)
156
+ npm run web:start # Web server (explicit)
157
+ npm run cli:start # CLI (built)
158
+ npm run electron:start # Electron app
159
+ ```
148
160
 
149
- **Module Dependencies:**
150
- - `web:dev` / `web:watch` → Depends on `server` (waits for server to be ready)
151
- - `tui:dev` / `tui:watch` Depends on `ws` (waits for WebSocket server)
161
+ ### Behind the Scenes
162
+ The scripts handle dependencies automatically:
163
+ - **Web**: Builds core, starts server in watch mode, launches Vite dev server
164
+ - **CLI**: Runs with tsx watch mode for instant feedback
165
+ - **Electron**: Builds core, launches Electron with Vite HMR
152
166
 
153
- **Examples:**
167
+ ### Other Useful Scripts
154
168
  ```bash
155
- # Production execution (requires build)
156
- npm run server # Runs: node dist/server/index.js
157
- npm run cli # Runs: node dist/cli/index.js
158
-
159
- # Development (no build needed)
160
- npm run server:dev # Runs: npx tsx server/index.ts
161
- npm run ws:dev # Runs: npx tsx ws/index.ts
162
-
163
- # Watch mode (auto-restart on changes)
164
- npm run server:watch # Runs: npx tsx --watch server/index.ts
165
- npm run cli:watch # Runs: npx tsx --watch cli/index.ts
166
-
167
- # With dependencies (auto-start required services)
168
- npm run web:dev # Waits for server, then starts web
169
- npm run web:watch # Runs server:watch + web in parallel
170
- npm run tui:dev # Waits for ws, then starts tui
171
- npm run tui:watch # Runs ws:watch + tui in parallel
169
+ npm run build # Build all (core + root + web)
170
+ npm run check # TypeScript type checking
171
+ npm test # Run unit tests
172
+ npm run test:watch # Watch mode
172
173
  ```
173
174
 
174
175
  ### Environment Setup
@@ -218,13 +219,13 @@ Agent World uses **scenario-based logging** to help you debug specific issues wi
218
219
 
219
220
  ```bash
220
221
  # Database migration issues
221
- LOG_STORAGE_MIGRATION=info npm run server
222
+ LOG_STORAGE_MIGRATION=info npm run web:dev
222
223
 
223
224
  # MCP server problems
224
- LOG_MCP=debug npm run server
225
+ LOG_MCP=debug npm run web:dev
225
226
 
226
227
  # Agent response debugging
227
- LOG_EVENTS_AGENT=debug LOG_LLM=debug npm run server
228
+ LOG_EVENTS_AGENT=debug LOG_LLM=debug npm run web:dev
228
229
  ```
229
230
 
230
231
  **For complete logging documentation**, see [Logging Guide](docs/logging-guide.md).
@@ -247,7 +248,9 @@ export AGENT_WORLD_DATA_PATH=./data/worlds
247
248
 
248
249
  - **[Building Agents with Just Words](docs/Building%20Agents%20with%20Just%20Words.md)** - Complete guide with examples
249
250
  - **[Shell Command Tool (shell_cmd)](docs/shell-cmd-tool.md)** - Built-in tool for executing shell commands
251
+ - **[HITL Approval Flow](docs/hitl-approval-flow.md)** - Option-based approval flow across Core/Electron/Web/CLI
250
252
  - **[Using Core from npm](docs/core-npm-usage.md)** - Integration guide for server and browser apps
253
+ - **[Electron Desktop App](docs/electron-desktop.md)** - Open-folder workflow and local world creation
251
254
 
252
255
 
253
256
  ## Built-in Tools
@@ -267,6 +270,37 @@ Execute shell commands with full output capture and execution history. Perfect f
267
270
 
268
271
  See [Shell Command Tool Documentation](docs/shell-cmd-tool.md) for complete details.
269
272
 
273
+ ### load_skill (Agent Skills)
274
+
275
+ Agent World includes progressive skill loading through the `load_skill` built-in tool.
276
+
277
+ - Skills are discovered from `SKILL.md` files in:
278
+ - Project roots: `.agents/skills`, `skills`
279
+ - User roots: `~/.agents/skills`, `~/.codex/skills`
280
+ - The model receives compact skill summaries first, then calls `load_skill` only when full instructions are needed.
281
+ - Skill activation in interactive runtimes is HITL-gated.
282
+
283
+ Minimal `SKILL.md` example:
284
+
285
+ ```md
286
+ ---
287
+ name: sql-review
288
+ description: Review SQL migrations for safety and rollback compatibility.
289
+ ---
290
+
291
+ # SQL Review Skill
292
+
293
+ 1. Check for destructive DDL.
294
+ 2. Verify index and lock impact.
295
+ 3. Validate rollback path.
296
+ ```
297
+
298
+ HITL options for skill activation:
299
+
300
+ - `yes_once`: approve this call only
301
+ - `yes_in_session`: approve this `skill_id` in the current world/chat session
302
+ - `no`: decline
303
+
270
304
  ## Experimental Features
271
305
 
272
306
  - **MCP Support** - *Currently in experiment* - Model Context Protocol integration for tools like search and code execution. e.g.,
@@ -308,4 +342,3 @@ Agent World thrives on community examples and improvements:
308
342
  MIT License - Build amazing things and share them with the world!
309
343
 
310
344
  Copyright © 2025 Yiyi Sun
311
-
package/dist/cli/index.js CHANGED
@@ -13,6 +13,9 @@
13
13
  * - Work with loaded world without importing (uses external storage path)
14
14
  *
15
15
  * Changes:
16
+ * - 2026-02-14: Added interactive + pipeline HITL option response handling for generic approval requests.
17
+ * - 2026-02-11: Fixed tool-stream timeout: extendTimeout() resets idle timeout on streaming data
18
+ * - 2026-02-11: Pipeline mode now listens for tool-stream SSE events to extend timeout
16
19
  * - 2026-01-09: Added --streaming flag for explicit streaming control (overrides TTY auto-detection)
17
20
  * - 2025-02-06: Prevented duplicate MESSAGE output when streaming already rendered agent responses
18
21
  * - 2025-11-01: Added multi-world selection in loadWorldFromFile
@@ -73,17 +76,20 @@ import path from 'path';
73
76
  import { fileURLToPath } from 'url';
74
77
  import { program } from 'commander';
75
78
  import readline from 'readline';
76
- import { listWorlds, subscribeWorld, createCategoryLogger, LLMProvider, enableStreaming, disableStreaming } from '../core/index.js';
79
+ import { listWorlds, subscribeWorld, submitWorldOptionResponse, createCategoryLogger, LLMProvider, enableStreaming, disableStreaming } from '../core/index.js';
77
80
  import { EventType } from '../core/types.js';
78
81
  import { getDefaultRootPath } from '../core/storage/storage-factory.js';
79
82
  import { processCLIInput, displayChatMessages } from './commands.js';
80
- import { createStreamingState, handleWorldEventWithStreaming, handleToolEvents, handleActivityEvents, } from './stream.js';
83
+ import { createStreamingState, handleWorldEventWithStreaming, handleToolEvents, } from './stream.js';
81
84
  import { configureLLMProvider } from '../core/llm-config.js';
85
+ import { createStatusLineManager, log as statusLog, } from './display.js';
86
+ import { parseHitlOptionRequest, resolveHitlOptionSelectionInput, } from './hitl.js';
82
87
  // Create CLI category logger after logger auto-initialization
83
88
  const logger = createCategoryLogger('cli');
84
89
  function createGlobalState() {
85
90
  return {
86
- awaitingResponse: false
91
+ awaitingResponse: false,
92
+ hitlPromptActive: false
87
93
  };
88
94
  }
89
95
  // Color helpers - consolidated styling API
@@ -163,7 +169,8 @@ class WorldActivityMonitor {
163
169
  cleanup();
164
170
  reject(error);
165
171
  },
166
- seenProcessing: false
172
+ seenProcessing: false,
173
+ timeoutMs
167
174
  };
168
175
  const cleanup = () => {
169
176
  if (waiter.timeoutId) {
@@ -208,6 +215,26 @@ class WorldActivityMonitor {
208
215
  }
209
216
  this.lastEvent = null;
210
217
  }
218
+ /**
219
+ * Extend timeout for all active waiters.
220
+ * Called when streaming data arrives to prevent premature timeout.
221
+ */
222
+ extendTimeout() {
223
+ for (const waiter of this.waiters) {
224
+ // Reset the main timeout
225
+ if (waiter.timeoutId) {
226
+ clearTimeout(waiter.timeoutId);
227
+ waiter.timeoutId = setTimeout(() => {
228
+ this.finishWaiter(waiter, false, new Error('Timed out waiting for world to become idle'));
229
+ }, waiter.timeoutMs);
230
+ }
231
+ // Clear noActivity timeout since we have activity
232
+ if (waiter.noActivityTimeoutId) {
233
+ clearTimeout(waiter.noActivityTimeoutId);
234
+ waiter.noActivityTimeoutId = undefined;
235
+ }
236
+ }
237
+ }
211
238
  getActiveSources() {
212
239
  return this.lastEvent?.activeSources ?? [];
213
240
  }
@@ -243,34 +270,37 @@ function parseActivitySource(source) {
243
270
  }
244
271
  return null;
245
272
  }
246
- class ActivityProgressRenderer {
247
- activeAgents = new Set();
248
- handle(event) {
249
- if (!event)
250
- return;
251
- // Reset on idle
252
- if (event.type === 'idle') {
253
- this.reset();
254
- return;
255
- }
256
- const details = parseActivitySource(event.source);
257
- if (!details || details.type !== 'agent') {
258
- return;
259
- }
260
- // Track agent start on response-start
261
- if (event.type === 'response-start' && !this.activeAgents.has(details.name)) {
262
- this.activeAgents.add(details.name);
263
- }
264
- // Track agent end on response-end
265
- if (event.type === 'response-end' && this.activeAgents.has(details.name)) {
266
- this.activeAgents.delete(details.name);
273
+ async function promptHitlOptionSelection(request, statusLine, rl) {
274
+ const fallbackOptionId = request.defaultOptionId;
275
+ statusLine.pause();
276
+ try {
277
+ while (true) {
278
+ console.log(`\n${boldMagenta('Approval Required')}`);
279
+ console.log(`${boldCyan(request.title)}`);
280
+ if (request.message) {
281
+ console.log(gray(request.message));
282
+ }
283
+ console.log(gray('Select an option:'));
284
+ request.options.forEach((option, index) => {
285
+ const isDefault = option.id === fallbackOptionId;
286
+ const defaultSuffix = isDefault ? gray(' (default)') : '';
287
+ console.log(` ${yellow(String(index + 1) + '.')} ${option.label}${defaultSuffix}`);
288
+ if (option.description) {
289
+ console.log(` ${gray(option.description)}`);
290
+ }
291
+ });
292
+ const answer = await new Promise((resolve) => {
293
+ rl.question(`${boldMagenta('Choice (number or option id):')} `, (input) => resolve(input.trim()));
294
+ });
295
+ const resolvedOptionId = resolveHitlOptionSelectionInput(request.options, answer, fallbackOptionId);
296
+ if (resolvedOptionId) {
297
+ return resolvedOptionId;
298
+ }
299
+ console.log(boldRed('Invalid selection. Please choose a listed option.'));
267
300
  }
268
301
  }
269
- reset() {
270
- if (this.activeAgents.size > 0) {
271
- this.activeAgents.clear();
272
- // console.log(gray('All agents finished.'));
273
- }
302
+ finally {
303
+ statusLine.resume();
274
304
  }
275
305
  }
276
306
  // LLM Provider configuration from environment variables
@@ -360,19 +390,19 @@ function printCLIResult(result) {
360
390
  * @param streaming - Streaming state for interactive mode (null for pipeline mode)
361
391
  * @param globalState - Global state for interactive mode (null for pipeline mode)
362
392
  * @param activityMonitor - Activity monitor for tracking world events
363
- * @param progressRenderer - Progress renderer for displaying activity
393
+ * @param statusLine - Status line manager for interactive display (null for pipeline mode)
364
394
  * @param rl - Readline interface for interactive mode (undefined for pipeline mode)
365
395
  * @returns Map of event types to listener functions for cleanup
366
396
  */
367
- function attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl) {
397
+ function attachCLIListeners(world, streaming, globalState, activityMonitor, statusLine, rl) {
368
398
  const listeners = new Map();
399
+ let hitlPromptChain = Promise.resolve();
369
400
  // World activity events
370
401
  const worldListener = (eventData) => {
371
402
  activityMonitor.handle(eventData);
372
403
  // Only render activity progress in interactive mode
373
- if (streaming && globalState && rl) {
374
- progressRenderer.handle(eventData);
375
- handleWorldEvent(EventType.WORLD, eventData, streaming, globalState, activityMonitor, progressRenderer, rl)
404
+ if (streaming && globalState && rl && statusLine) {
405
+ handleWorldEvent(EventType.WORLD, eventData, streaming, globalState, activityMonitor, statusLine, rl)
376
406
  .catch(err => console.error('Error handling world event:', err));
377
407
  }
378
408
  // Pipeline mode: silently track events for completion detection
@@ -385,8 +415,8 @@ function attachCLIListeners(world, streaming, globalState, activityMonitor, prog
385
415
  typeof eventData.content === 'string' &&
386
416
  eventData.content.includes('Success message sent'))
387
417
  return;
388
- if (streaming && globalState && rl) {
389
- handleWorldEvent(EventType.MESSAGE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl)
418
+ if (streaming && globalState && rl && statusLine) {
419
+ handleWorldEvent(EventType.MESSAGE, eventData, streaming, globalState, activityMonitor, statusLine, rl)
390
420
  .catch(err => console.error('Error handling message event:', err));
391
421
  }
392
422
  else {
@@ -402,22 +432,81 @@ function attachCLIListeners(world, streaming, globalState, activityMonitor, prog
402
432
  world.eventEmitter.on(EventType.MESSAGE, messageListener);
403
433
  listeners.set(EventType.MESSAGE, messageListener);
404
434
  // SSE events (interactive mode only - pipeline mode uses non-streaming LLM calls)
405
- if (streaming && globalState && rl) {
435
+ if (streaming && globalState && rl && statusLine) {
406
436
  const sseListener = (eventData) => {
407
- handleWorldEvent(EventType.SSE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl)
437
+ // Extend timeout when tool-stream data arrives (keeps long-running tools alive)
438
+ if (eventData.type === 'tool-stream') {
439
+ activityMonitor.extendTimeout();
440
+ }
441
+ handleWorldEvent(EventType.SSE, eventData, streaming, globalState, activityMonitor, statusLine, rl)
408
442
  .catch(err => console.error('Error handling SSE event:', err));
409
443
  };
410
444
  world.eventEmitter.on(EventType.SSE, sseListener);
411
445
  listeners.set(EventType.SSE, sseListener);
412
446
  }
447
+ else {
448
+ // Pipeline mode: listen for tool-stream events to extend timeout on long-running commands
449
+ const sseTimeoutListener = (eventData) => {
450
+ if (eventData.type === 'tool-stream') {
451
+ activityMonitor.extendTimeout();
452
+ }
453
+ };
454
+ world.eventEmitter.on(EventType.SSE, sseTimeoutListener);
455
+ listeners.set(EventType.SSE, sseTimeoutListener);
456
+ }
413
457
  // System events
414
458
  const systemListener = (eventData) => {
459
+ const hitlRequest = parseHitlOptionRequest(eventData);
460
+ if (hitlRequest) {
461
+ hitlPromptChain = hitlPromptChain
462
+ .then(async () => {
463
+ if (streaming && globalState && rl && statusLine) {
464
+ globalState.hitlPromptActive = true;
465
+ try {
466
+ const selectedOptionId = await promptHitlOptionSelection(hitlRequest, statusLine, rl);
467
+ const result = submitWorldOptionResponse({
468
+ worldId: world.id,
469
+ requestId: hitlRequest.requestId,
470
+ optionId: selectedOptionId
471
+ });
472
+ if (!result.accepted) {
473
+ statusLine.pause();
474
+ console.log(boldRed(`Failed to submit approval response: ${result.reason || 'unknown error'}`));
475
+ statusLine.resume();
476
+ return;
477
+ }
478
+ statusLine.pause();
479
+ console.log(green(`Approved option: ${selectedOptionId}`));
480
+ statusLine.resume();
481
+ return;
482
+ }
483
+ finally {
484
+ globalState.hitlPromptActive = false;
485
+ }
486
+ }
487
+ // Pipeline/non-interactive mode: auto-respond with default option to avoid blocking.
488
+ const result = submitWorldOptionResponse({
489
+ worldId: world.id,
490
+ requestId: hitlRequest.requestId,
491
+ optionId: hitlRequest.defaultOptionId
492
+ });
493
+ if (!result.accepted) {
494
+ console.error(boldRed(`Failed to auto-respond HITL request: ${result.reason || 'unknown error'}`));
495
+ return;
496
+ }
497
+ console.log(`${gray('● system:')} Auto-selected HITL option "${hitlRequest.defaultOptionId}"`);
498
+ })
499
+ .catch((error) => {
500
+ console.error(boldRed(`Error handling HITL request: ${error instanceof Error ? error.message : String(error)}`));
501
+ });
502
+ return;
503
+ }
415
504
  if (eventData.content &&
416
505
  typeof eventData.content === 'string' &&
417
506
  eventData.content.includes('Success message sent'))
418
507
  return;
419
- if (streaming && globalState && rl) {
420
- handleWorldEvent(EventType.SYSTEM, eventData, streaming, globalState, activityMonitor, progressRenderer, rl)
508
+ if (streaming && globalState && rl && statusLine) {
509
+ handleWorldEvent(EventType.SYSTEM, eventData, streaming, globalState, activityMonitor, statusLine, rl)
421
510
  .catch(err => console.error('Error handling system event:', err));
422
511
  }
423
512
  else if (eventData.message || eventData.content) {
@@ -446,7 +535,6 @@ async function runPipelineMode(options, messageFromArgs) {
446
535
  let worldSubscription = null;
447
536
  let cliListeners = null;
448
537
  const activityMonitor = new WorldActivityMonitor();
449
- const progressRenderer = new ActivityProgressRenderer();
450
538
  try {
451
539
  if (options.world) {
452
540
  // Subscribe to world lifecycle but do not request forwarding callbacks
@@ -458,7 +546,7 @@ async function runPipelineMode(options, messageFromArgs) {
458
546
  world = worldSubscription.world;
459
547
  // Attach direct listeners to the world.eventEmitter for pipeline handling
460
548
  // Note: Pipeline mode uses non-streaming LLM calls, so SSE events are not needed
461
- cliListeners = attachCLIListeners(world, null, null, activityMonitor, progressRenderer);
549
+ cliListeners = attachCLIListeners(world, null, null, activityMonitor, null);
462
550
  }
463
551
  // Execute command from --command option
464
552
  if (options.command) {
@@ -587,11 +675,11 @@ function cleanupWorldSubscription(worldState) {
587
675
  * @param streaming - Streaming state for real-time response display
588
676
  * @param globalState - Global state for timer management
589
677
  * @param activityMonitor - Activity monitor for tracking world events
590
- * @param progressRenderer - Progress renderer for displaying activity
678
+ * @param statusLine - Status line manager for interactive display
591
679
  * @param rl - Readline interface for interactive input
592
680
  * @returns WorldState with subscription and world instance
593
681
  */
594
- async function handleSubscribe(rootPath, worldName, streaming, globalState, activityMonitor, progressRenderer, rl) {
682
+ async function handleSubscribe(rootPath, worldName, streaming, globalState, activityMonitor, statusLine, rl) {
595
683
  // Subscribe to world lifecycle but do not request forwarding callbacks
596
684
  const subscription = await subscribeWorld(worldName, { isOpen: true });
597
685
  if (!subscription)
@@ -603,36 +691,87 @@ async function handleSubscribe(rootPath, worldName, streaming, globalState, acti
603
691
  }
604
692
  // Attach direct listeners to the world.eventEmitter for CLI handling
605
693
  // Interactive mode needs all event types including SSE for streaming responses
606
- attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl);
694
+ attachCLIListeners(world, streaming, globalState, activityMonitor, statusLine, rl);
607
695
  return { subscription, world };
608
696
  }
609
697
  // Handle world events with streaming support
610
- async function handleWorldEvent(eventType, eventData, streaming, globalState, activityMonitor, progressRenderer, rl) {
698
+ async function handleWorldEvent(eventType, eventData, streaming, globalState, activityMonitor, statusLine, rl) {
611
699
  if (eventType === 'world') {
612
700
  const payload = eventData;
613
701
  // Handle activity events (new format: type = 'response-start' | 'response-end' | 'idle')
614
702
  if (payload.type === 'response-start' || payload.type === 'response-end' || payload.type === 'idle') {
615
703
  activityMonitor.handle(payload);
616
- progressRenderer.handle(payload);
617
- // Call handleActivityEvents for verbose activity logging
618
- handleActivityEvents(payload);
619
- if (payload.type === 'idle' && rl && globalState.awaitingResponse) {
620
- globalState.awaitingResponse = false;
621
- console.log(); // Empty line before prompt
622
- rl.prompt();
704
+ // Update status line based on activity events
705
+ const details = parseActivitySource(payload.source);
706
+ const agentName = details?.type === 'agent' ? details.name : payload.source || '';
707
+ if (payload.type === 'response-start' && agentName) {
708
+ statusLine.setSpinner(`${agentName} is thinking...`);
709
+ statusLine.startElapsedTimer();
710
+ // Update agent list from activeSources
711
+ if (payload.activeSources) {
712
+ const agentEntries = payload.activeSources
713
+ .map((s) => parseActivitySource(s))
714
+ .filter((d) => d?.type === 'agent')
715
+ .map((d) => ({ name: d.name, active: true }));
716
+ statusLine.setAgents(agentEntries);
717
+ }
718
+ }
719
+ if (payload.type === 'response-end') {
720
+ // Update agent list — remove finished agent
721
+ if (payload.activeSources) {
722
+ const agentEntries = payload.activeSources
723
+ .map((s) => parseActivitySource(s))
724
+ .filter((d) => d?.type === 'agent')
725
+ .map((d) => ({ name: d.name, active: true }));
726
+ statusLine.setAgents(agentEntries);
727
+ // Update spinner label to next active agent if any
728
+ if (agentEntries.length > 0) {
729
+ statusLine.setSpinner(`${agentEntries[0].name} is thinking...`);
730
+ }
731
+ else {
732
+ statusLine.setSpinner(null);
733
+ statusLine.stopElapsedTimer();
734
+ }
735
+ }
736
+ }
737
+ if (payload.type === 'idle') {
738
+ statusLine.reset();
739
+ if (rl && globalState.awaitingResponse) {
740
+ globalState.awaitingResponse = false;
741
+ statusLine.clear();
742
+ console.log(); // Empty line before prompt
743
+ rl.prompt();
744
+ }
623
745
  }
624
746
  }
625
747
  // Handle informational world messages.
626
748
  else if (payload.type === 'info' && payload.message) {
627
- console.log(`${gray('[World]')} ${payload.message}`);
749
+ statusLog(statusLine, `${gray('[World]')} ${payload.message}`);
628
750
  }
629
751
  // Handle tool events (migrated from sse channel)
630
752
  else if (payload.type === 'tool-start' || payload.type === 'tool-result' || payload.type === 'tool-error' || payload.type === 'tool-progress') {
753
+ // Update status line with tool info
754
+ if (payload.type === 'tool-start' && payload.toolExecution) {
755
+ statusLine.addTool(payload.toolExecution.toolName);
756
+ }
757
+ else if (payload.type === 'tool-result' && payload.toolExecution) {
758
+ statusLine.removeTool(payload.toolExecution.toolName, 'done');
759
+ }
760
+ else if (payload.type === 'tool-error' && payload.toolExecution) {
761
+ statusLine.removeTool(payload.toolExecution.toolName, 'error');
762
+ }
763
+ // Print permanent tool event output
764
+ statusLine.pause();
631
765
  handleToolEvents(payload);
766
+ // Only resume status line if not actively streaming
767
+ // (streaming code manages its own pause/resume lifecycle)
768
+ if (!streaming.isActive) {
769
+ statusLine.resume();
770
+ }
632
771
  }
633
772
  return;
634
773
  }
635
- if (handleWorldEventWithStreaming(eventType, eventData, streaming)) {
774
+ if (handleWorldEventWithStreaming(eventType, eventData, streaming, statusLine)) {
636
775
  return;
637
776
  }
638
777
  if (eventData.content &&
@@ -662,12 +801,12 @@ async function handleWorldEvent(eventType, eventData, streaming, globalState, ac
662
801
  }
663
802
  // Display system messages
664
803
  if (eventData.sender === 'system') {
665
- console.log(`${boldRed('● system:')} ${eventData.content}`);
804
+ statusLog(statusLine, `${boldRed('● system:')} ${eventData.content}`);
666
805
  return;
667
806
  }
668
807
  // Display agent messages (fallback for non-streaming or missed messages)
669
808
  if (eventData.content) {
670
- console.log(`\n${boldGreen(`● ${eventData.sender}:`)} ${eventData.content}\n`);
809
+ statusLog(statusLine, `\n${boldGreen(`● ${eventData.sender}:`)} ${eventData.content}\n`);
671
810
  }
672
811
  return;
673
812
  }
@@ -1005,7 +1144,7 @@ async function runInteractiveMode(options) {
1005
1144
  const globalState = createGlobalState();
1006
1145
  const streaming = createStreamingState();
1007
1146
  const activityMonitor = new WorldActivityMonitor();
1008
- const progressRenderer = new ActivityProgressRenderer();
1147
+ const statusLine = createStatusLineManager();
1009
1148
  const rl = readline.createInterface({
1010
1149
  input: process.stdin,
1011
1150
  output: process.stdout,
@@ -1022,8 +1161,8 @@ async function runInteractiveMode(options) {
1022
1161
  logger.debug(`Loading world: ${options.world}`);
1023
1162
  try {
1024
1163
  activityMonitor.reset();
1025
- progressRenderer.reset();
1026
- worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, activityMonitor, progressRenderer, rl);
1164
+ statusLine.reset();
1165
+ worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, activityMonitor, statusLine, rl);
1027
1166
  currentWorldName = options.world;
1028
1167
  console.log(success(`Connected to world: ${currentWorldName}`));
1029
1168
  if (worldState?.world) {
@@ -1048,8 +1187,8 @@ async function runInteractiveMode(options) {
1048
1187
  logger.debug(`Loading world: ${selectedWorld.worldName} from ${effectiveRootPath}`);
1049
1188
  try {
1050
1189
  activityMonitor.reset();
1051
- progressRenderer.reset();
1052
- worldState = await handleSubscribe(effectiveRootPath, selectedWorld.worldName, streaming, globalState, activityMonitor, progressRenderer, rl);
1190
+ statusLine.reset();
1191
+ worldState = await handleSubscribe(effectiveRootPath, selectedWorld.worldName, streaming, globalState, activityMonitor, statusLine, rl);
1053
1192
  currentWorldName = selectedWorld.worldName;
1054
1193
  console.log(success(`Connected to world: ${currentWorldName}`));
1055
1194
  if (selectedWorld.externalPath) {
@@ -1082,6 +1221,9 @@ async function runInteractiveMode(options) {
1082
1221
  console.log(); // Empty line before prompt
1083
1222
  rl.prompt();
1084
1223
  rl.on('line', async (input) => {
1224
+ if (globalState.hitlPromptActive) {
1225
+ return;
1226
+ }
1085
1227
  const trimmedInput = input.trim();
1086
1228
  if (!trimmedInput) {
1087
1229
  console.log(); // Empty line before prompt
@@ -1145,8 +1287,8 @@ async function runInteractiveMode(options) {
1145
1287
  // Subscribe to the new world
1146
1288
  logger.debug(`Subscribing to world: ${selectedWorld.worldName}...`);
1147
1289
  activityMonitor.reset();
1148
- progressRenderer.reset();
1149
- worldState = await handleSubscribe(effectiveRootPath, selectedWorld.worldName, streaming, globalState, activityMonitor, progressRenderer, rl);
1290
+ statusLine.reset();
1291
+ worldState = await handleSubscribe(effectiveRootPath, selectedWorld.worldName, streaming, globalState, activityMonitor, statusLine, rl);
1150
1292
  currentWorldName = selectedWorld.worldName;
1151
1293
  console.log(success(`Connected to world: ${currentWorldName}`));
1152
1294
  if (selectedWorld.externalPath) {
@@ -1354,6 +1496,7 @@ async function runInteractiveMode(options) {
1354
1496
  if (isExiting)
1355
1497
  return; // Prevent duplicate cleanup
1356
1498
  isExiting = true;
1499
+ statusLine.cleanup();
1357
1500
  console.log(`\n${boldCyan('Goodbye!')}`);
1358
1501
  if (worldState)
1359
1502
  cleanupWorldSubscription(worldState);
@@ -1363,6 +1506,7 @@ async function runInteractiveMode(options) {
1363
1506
  if (isExiting)
1364
1507
  return; // Prevent duplicate cleanup
1365
1508
  isExiting = true;
1509
+ statusLine.cleanup();
1366
1510
  console.log(`\n${boldCyan('Shutting down...')}`);
1367
1511
  console.log(`\n${boldCyan('Goodbye!')}`);
1368
1512
  if (worldState)
@@ -1373,6 +1517,7 @@ async function runInteractiveMode(options) {
1373
1517
  }
1374
1518
  catch (err) {
1375
1519
  console.error(boldRed('Error starting interactive mode:'), err instanceof Error ? err.message : err);
1520
+ statusLine.cleanup();
1376
1521
  rl.close();
1377
1522
  process.exit(1);
1378
1523
  }
@@ -8,9 +8,17 @@
8
8
  * - Category-based logging with configurable levels
9
9
  * - Health check endpoint and proper error handling
10
10
  * - Environment Variables: Automatically loads .env file for API keys and configuration
11
+ * - Controlled startup behavior for direct execution vs imported usage
12
+ * - Optional browser auto-open and process signal handler registration
11
13
  *
12
14
  * Configuration: AGENT_WORLD_DATA_PATH, LOG_LEVEL, LLM provider keys
13
15
  * Endpoints: /health + API routes from ./api.ts
16
+ *
17
+ * Recent Changes:
18
+ * - 2026-02-08: Fixed auto-run detection to only trigger on direct execution
19
+ * - 2026-02-08: Made browser auto-open configurable via AGENT_WORLD_AUTO_OPEN env var
20
+ * - 2026-02-08: Prevented duplicate process signal handler registration using WeakSet
21
+ * - 2026-02-08: Added shutdown guard to prevent race conditions during graceful shutdown
14
22
  */
15
23
  // Load environment variables from .env file
16
24
  import dotenv from 'dotenv';
@@ -32,6 +40,8 @@ const PORT = Number(process.env.PORT) || 0;
32
40
  const HOST = process.env.HOST || '127.0.0.1';
33
41
  // Create server logger after logger auto-initialization
34
42
  const serverLogger = createCategoryLogger('server');
43
+ // Track servers that have registered process handlers (prevents duplicate registration)
44
+ const serversWithHandlers = new WeakSet();
35
45
  // LLM provider configuration
36
46
  function configureLLMProvidersFromEnv() {
37
47
  const providers = [
@@ -114,7 +124,9 @@ app.use((req, res) => {
114
124
  res.status(404).json({ error: 'Endpoint not found', code: 'NOT_FOUND' });
115
125
  });
116
126
  // Server startup function
117
- export function startWebServer(port = PORT, host = HOST) {
127
+ export function startWebServer(port = PORT, host = HOST, options = {}) {
128
+ const openBrowser = options.openBrowser ?? false;
129
+ const registerProcessHandlers = options.registerProcessHandlers ?? false;
118
130
  return new Promise((resolve, reject) => {
119
131
  configureLLMProvidersFromEnv();
120
132
  // Initialize MCP registry
@@ -129,12 +141,23 @@ export function startWebServer(port = PORT, host = HOST) {
129
141
  // console.log(`📁 Serving static files from: ${path.join(__dirname, '../public')}`);
130
142
  // console.log(`🚀 HTTP server running with REST API and SSE chat`);
131
143
  resolve(server);
132
- open(url);
144
+ if (openBrowser) {
145
+ open(url).catch((error) => {
146
+ serverLogger.warn('Failed to open browser automatically', {
147
+ error: error instanceof Error ? error.message : String(error),
148
+ url
149
+ });
150
+ });
151
+ }
133
152
  }
134
153
  });
135
154
  server.on('error', reject);
136
155
  // Setup graceful shutdown handlers
156
+ let shuttingDown = false;
137
157
  const gracefulShutdown = async (signal) => {
158
+ if (shuttingDown)
159
+ return;
160
+ shuttingDown = true;
138
161
  serverLogger.info(`Received ${signal}, initiating graceful shutdown`);
139
162
  try {
140
163
  // Shutdown MCP servers first
@@ -159,29 +182,41 @@ export function startWebServer(port = PORT, host = HOST) {
159
182
  process.exit(1);
160
183
  }
161
184
  };
162
- // Register shutdown handlers
163
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
164
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
165
- // Handle uncaught exceptions and unhandled rejections
166
- process.on('uncaughtException', (error) => {
167
- serverLogger.error('Uncaught exception', { error: error.message });
168
- gracefulShutdown('uncaughtException');
169
- });
170
- process.on('unhandledRejection', (reason, promise) => {
171
- serverLogger.error('Unhandled rejection', {
172
- reason: reason instanceof Error ? reason.message : reason,
173
- promise: promise.toString()
185
+ if (registerProcessHandlers && !serversWithHandlers.has(server)) {
186
+ serversWithHandlers.add(server);
187
+ // Register shutdown handlers once
188
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
189
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
190
+ // Handle uncaught exceptions and unhandled rejections
191
+ process.on('uncaughtException', (error) => {
192
+ serverLogger.error('Uncaught exception', { error: error.message });
193
+ gracefulShutdown('uncaughtException');
174
194
  });
175
- });
195
+ process.on('unhandledRejection', (reason, promise) => {
196
+ serverLogger.error('Unhandled rejection', {
197
+ reason: reason instanceof Error ? reason.message : reason,
198
+ promise: promise.toString()
199
+ });
200
+ });
201
+ }
176
202
  });
177
203
  }
178
- // Direct execution handling - check both direct execution and npm bin execution
204
+ // Direct execution handling
179
205
  const currentFileUrl = import.meta.url;
180
- const entryPointUrl = pathToFileURL(path.resolve(process.argv[1])).href;
206
+ const entryPointUrl = process.argv[1]
207
+ ? pathToFileURL(path.resolve(process.argv[1])).href
208
+ : '';
181
209
  const isDirectExecution = currentFileUrl === entryPointUrl;
182
- const isServerBinCommand = process.argv[1].includes('agent-world-server') || currentFileUrl.includes('server/index.js');
183
- if (isDirectExecution || isServerBinCommand) {
184
- startWebServer()
210
+ const isBinExecution = process.argv[1]?.includes('agent-world-server') || false;
211
+ // Auto-open browser by default when launched via npx/bin, unless explicitly disabled
212
+ const shouldOpenBrowser = isBinExecution
213
+ ? process.env.AGENT_WORLD_AUTO_OPEN !== 'false'
214
+ : process.env.AGENT_WORLD_AUTO_OPEN === 'true';
215
+ if (isDirectExecution || isBinExecution) {
216
+ startWebServer(PORT, HOST, {
217
+ openBrowser: shouldOpenBrowser,
218
+ registerProcessHandlers: true
219
+ })
185
220
  .then(() => console.log('Server started successfully'))
186
221
  .catch((error) => {
187
222
  console.error('Failed to start server:', error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-world",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "exports": {
@@ -17,32 +17,30 @@
17
17
  "type": "module",
18
18
  "workspaces": [
19
19
  "core",
20
- "react",
21
20
  "web"
22
21
  ],
23
22
  "bin": {
24
- "agent-world": "dist/cli/index.js",
23
+ "agent-world": "scripts/launch-electron.js",
24
+ "agent-world-cli": "dist/cli/index.js",
25
25
  "agent-world-server": "dist/server/index.js"
26
26
  },
27
27
  "scripts": {
28
- "prestart": "npm run build",
29
- "start": "node dist/server/index.js",
30
- "dev": "npm-run-all --parallel server:watch web:dev react:dev",
31
- "cli": "npm run cli:start",
32
- "cli:start": "node dist/cli/index.js",
33
- "cli:dev": "npx tsx cli/index.ts",
34
- "cli:watch": "npx tsx --watch cli/index.ts",
35
- "server": "npm run server:start",
36
- "server:start": "node dist/server/index.js",
28
+ "dev": "npm run web:dev",
29
+ "web:dev": "npm-run-all --parallel server:watch web:vite",
30
+ "cli:dev": "npx tsx --watch cli/index.ts",
31
+ "electron:dev": "npm-run-all --sequential build:core electron:vite",
32
+ "start": "npm run web:start",
33
+ "web:start": "npm run build && npm run server:start",
34
+ "cli:start": "npm run build && node dist/cli/index.js",
35
+ "electron:start": "npm run build:core && npm run start --prefix electron",
36
+ "build": "npm run build --workspace=core && tsc --project tsconfig.build.json && npm run build --workspace=web",
37
+ "build:core": "npm run build --workspace=core",
38
+ "check": "tsc --noEmit --project tsconfig.build.json && npm run check --workspace=core && npm run check --workspace=web",
37
39
  "server:dev": "npx tsx server/index.ts",
38
40
  "server:watch": "npx tsx --watch server/index.ts",
39
- "server:wait": "wait-on http://127.0.0.1:3000/health",
40
- "web:dev": "npm-run-all --sequential server:wait web:dev:direct",
41
- "web:dev:direct": "npm run dev --workspace=web",
42
- "web:watch": "npm-run-all --parallel server:watch web:dev:direct",
43
- "react:dev": "npm-run-all --parallel server:wait react:dev:direct",
44
- "react:dev:direct": "npm run dev --workspace=react",
45
- "react:watch": "npm-run-all --parallel server:watch react:dev:direct",
41
+ "server:start": "node dist/server/index.js",
42
+ "web:vite": "npm run dev --workspace=web",
43
+ "electron:vite": "npm run dev --prefix electron",
46
44
  "test": "vitest run",
47
45
  "test:watch": "vitest",
48
46
  "test:ui": "vitest --ui",
@@ -51,8 +49,6 @@
51
49
  "test:e2e": "npx tsx tests/e2e/test-agent-response-rules.ts",
52
50
  "test:e2e:interactive": "npx tsx tests/e2e/test-agent-response-rules.ts -i",
53
51
  "integration": "vitest run --config vitest.integration.config.ts",
54
- "check": "tsc --noEmit --project tsconfig.build.json && npm run check --workspace=core && npm run check --workspace=web && npm run check --workspace=react",
55
- "build": "npm run build --workspace=core && tsc --project tsconfig.build.json && npm run build --workspace=web && npm run build --workspace=react",
56
52
  "pkill": "pkill -f tsx"
57
53
  },
58
54
  "description": "World-mediated agent management system with clean API surface",
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Launch Electron App - Simple launcher for agent-world desktop app
5
+ *
6
+ * Purpose:
7
+ * - Launch the Electron desktop app when running `agent-world` command
8
+ * - Ensures necessary builds exist before launching
9
+ *
10
+ * Features:
11
+ * - Checks for core build
12
+ * - Launches Electron app
13
+ * - Provides helpful error messages if builds are missing
14
+ *
15
+ * Implementation Notes:
16
+ * - Used as bin entry point in package.json
17
+ * - Replaces CLI as default when running `agent-world` command
18
+ */
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+ import path from 'node:path';
23
+ import fs from 'node:fs';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+ const projectRoot = path.resolve(__dirname, '..');
28
+
29
+ // Check if core build exists
30
+ const coreIndexPath = path.join(projectRoot, 'dist', 'core', 'index.js');
31
+ if (!fs.existsSync(coreIndexPath)) {
32
+ console.error('Error: Core build not found. Please run: npm run build');
33
+ process.exit(1);
34
+ }
35
+
36
+ // Check if electron directory exists
37
+ const electronPath = path.join(projectRoot, 'electron');
38
+ if (!fs.existsSync(electronPath)) {
39
+ console.error('Error: Electron app not found.');
40
+ process.exit(1);
41
+ }
42
+
43
+ // Launch Electron app
44
+ const electronMainPath = path.join(electronPath, 'main.js');
45
+ const electronProcess = spawn('npx', ['electron', electronMainPath], {
46
+ cwd: projectRoot,
47
+ stdio: 'inherit',
48
+ env: { ...process.env }
49
+ });
50
+
51
+ electronProcess.on('error', (error) => {
52
+ console.error('Failed to launch Electron app:', error);
53
+ process.exit(1);
54
+ });
55
+
56
+ electronProcess.on('exit', (code) => {
57
+ process.exit(code || 0);
58
+ });