agent-state-machine 1.0.3 → 1.2.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
@@ -44,10 +44,11 @@ Requirements: Node.js >= 16.
44
44
  ```bash
45
45
  state-machine --setup <workflow-name>
46
46
  state-machine run <workflow-name>
47
- state-machine resume <workflow-name>
48
- state-machine status <workflow-name>
47
+
48
+ state-machine follow <workflow-name> (view prompt trace history in browser with live updates)
49
49
  state-machine history <workflow-name> [limit]
50
- state-machine reset <workflow-name>
50
+ state-machine reset <workflow-name> (clears memory/state)
51
+ state-machine reset-hard <workflow-name> (clears everything: history/interactions/memory)
51
52
  ```
52
53
 
53
54
  Workflows live in:
@@ -58,7 +59,7 @@ workflows/<name>/
58
59
  ├── package.json # Sets "type": "module" for this workflow folder
59
60
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
60
61
  ├── interactions/ # Human-in-the-loop files (auto-created)
61
- ├── state/ # current.json, history.jsonl, generated-prompt.md
62
+ ├── state/ # current.json, history.jsonl
62
63
  └── steering/ # global.md + config.json
63
64
  ```
64
65
 
@@ -130,13 +131,13 @@ export default async function() {
130
131
  }
131
132
  ```
132
133
 
133
- ### How “resume” works
134
+ ### Resuming workflows
134
135
 
135
- `resume` restarts your workflow from the top.
136
+ `state-machine run` restarts your workflow from the top, loading the persisted state.
136
137
 
137
138
  If the workflow needs human input, it will **block inline** in the terminal. You’ll be told which `interactions/<slug>.md` file to edit; after you fill it in, press `y` in the same terminal session to continue.
138
139
 
139
- If the process is interrupted, running `state-machine resume <workflow-name>` will restart the execution. Use the `memory` object to store and skip work manually if needed.
140
+ If the process is interrupted, running `state-machine run <workflow-name>` again will continue execution (assuming your workflow uses `memory` to skip completed steps).
140
141
 
141
142
  ---
142
143
 
@@ -292,10 +293,10 @@ export const config = {
292
293
  };
293
294
  ```
294
295
 
295
- The runtime writes the fully-built prompt to:
296
+ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates via:
296
297
 
297
- ```text
298
- workflows/<name>/state/generated-prompt.md
298
+ ```bash
299
+ state-machine follow <workflow-name>
299
300
  ```
300
301
 
301
302
  ---
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@ import fs from 'fs';
5
5
  import { pathToFileURL, fileURLToPath } from 'url';
6
6
  import { WorkflowRuntime } from '../lib/index.js';
7
7
  import { setup } from '../lib/setup.js';
8
+ import { startServer } from '../lib/ui/server.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
@@ -28,12 +29,12 @@ Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
28
29
 
29
30
  Usage:
30
31
  state-machine --setup <workflow-name> Create a new workflow project
31
- state-machine run <workflow-name> Run a workflow from the beginning
32
- state-machine resume <workflow-name> Resume a paused workflow
32
+ state-machine run <workflow-name> Run a workflow (loads existing state)
33
+ state-machine follow <workflow-name> View prompt trace history in browser with live updates
33
34
  state-machine status [workflow-name] Show current state (or list all)
34
- state-machine history <workflow-name> [limit] Show execution history
35
- state-machine reset <workflow-name> Reset workflow state
36
- state-machine reset-hard <workflow-name> Hard reset (clear history/interactions)
35
+ state-machine history <workflow-name> [limit] Show execution history logs
36
+ state-machine reset <workflow-name> Reset workflow state (clears memory/state)
37
+ state-machine reset-hard <workflow-name> Hard reset (clears everything: history/interactions/memory)
37
38
  state-machine list List all workflows
38
39
  state-machine help Show this help
39
40
 
@@ -48,7 +49,7 @@ Workflow Structure:
48
49
  ├── package.json # Sets "type": "module" for this workflow folder
49
50
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
50
51
  ├── interactions/ # Human-in-the-loop files (auto-created)
51
- ├── state/ # current.json, history.jsonl, generated-prompt.md
52
+ ├── state/ # current.json, history.jsonl
52
53
  └── steering/ # global.md + config.json
53
54
  `);
54
55
  }
@@ -182,7 +183,6 @@ async function main() {
182
183
 
183
184
  switch (command) {
184
185
  case 'run':
185
- case 'resume':
186
186
  if (!workflowName) {
187
187
  console.error('Error: Workflow name required');
188
188
  console.error(`Usage: state-machine ${command} <workflow-name>`);
@@ -222,6 +222,23 @@ async function main() {
222
222
  }
223
223
  break;
224
224
 
225
+ case 'follow':
226
+ if (!workflowName) {
227
+ console.error('Error: Workflow name required');
228
+ console.error('Usage: state-machine follow <workflow-name>');
229
+ process.exit(1);
230
+ }
231
+ {
232
+ const workflowDir = resolveWorkflowDir(workflowName);
233
+ if (!fs.existsSync(workflowDir)) {
234
+ console.error(`Error: Workflow '${workflowName}' not found`);
235
+ process.exit(1);
236
+ }
237
+ startServer(workflowDir);
238
+ // Do not exit, server needs to stay alive
239
+ }
240
+ break;
241
+
225
242
  case 'reset':
226
243
  if (!workflowName) {
227
244
  console.error('Error: Workflow name required');
package/lib/llm.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
+ import os from 'os';
7
8
  import { spawn, execSync } from 'child_process';
8
9
  import { createRequire } from 'module';
9
10
 
@@ -38,21 +39,6 @@ export function detectAvailableCLIs() {
38
39
  return available;
39
40
  }
40
41
 
41
- /**
42
- * Write the generated prompt file
43
- */
44
- export function writeGeneratedPrompt(workflowDir, content) {
45
- const promptDir = path.join(workflowDir, 'state');
46
- const promptFile = path.join(promptDir, 'generated-prompt.md');
47
-
48
- if (!fs.existsSync(promptDir)) {
49
- fs.mkdirSync(promptDir, { recursive: true });
50
- }
51
-
52
- fs.writeFileSync(promptFile, content);
53
- return promptFile;
54
- }
55
-
56
42
  /**
57
43
  * Build the full prompt with steering and context
58
44
  */
@@ -96,17 +82,18 @@ export function buildPrompt(context, options) {
96
82
 
97
83
  /**
98
84
  * Execute CLI command and return response
85
+ * Uses Stdin for supported tools, and temporary files for generic tools.
99
86
  */
100
- async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
87
+ async function executeCLI(command, promptText, options = {}, apiKeys = {}) {
101
88
  return new Promise((resolve, reject) => {
102
89
  // Parse command to extract base command and args
103
- // Note: naive split; if you need quoted args, consider a shell-args parser.
104
90
  const parts = command.split(' ');
105
91
  const baseCmd = parts[0];
106
92
  const baseArgs = parts.slice(1);
107
93
 
108
94
  // Build full args
109
95
  const args = [...baseArgs];
96
+ let tempPromptFile = null;
110
97
 
111
98
  const ensureCodexExec = () => {
112
99
  const CODEX_SUBCOMMANDS = new Set([
@@ -140,7 +127,7 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
140
127
  '-o', '--output-last-message'
141
128
  ]);
142
129
 
143
- // Insert `exec` after any leading global options so codex doesn't start interactive mode.
130
+ // Insert `exec` after any leading global options
144
131
  let i = 0;
145
132
  while (i < args.length) {
146
133
  const token = args[i];
@@ -150,7 +137,6 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
150
137
  i += 2;
151
138
  continue;
152
139
  }
153
-
154
140
  i += 1;
155
141
  }
156
142
 
@@ -158,41 +144,38 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
158
144
  if (firstNonOption && CODEX_SUBCOMMANDS.has(firstNonOption)) {
159
145
  return;
160
146
  }
161
-
162
147
  args.splice(i, 0, 'exec');
163
148
  };
164
149
 
165
- // Different CLIs handle file input differently
150
+ // Configure args based on the tool
151
+ const isStandardCLI = (baseCmd === 'claude' || baseCmd === 'gemini' || baseCmd === 'codex');
152
+
166
153
  if (baseCmd === 'claude') {
167
- // Claude CLI: use stdin for prompt input
168
- args.push('--print'); // Print response only
154
+ args.push('--print');
169
155
  args.push('--permission-mode', 'acceptEdits');
170
- // File content will be piped via stdin (no additional args needed)
156
+ // Input via stdin
171
157
  } else if (baseCmd === 'gemini') {
172
- // Gemini CLI
173
158
  args.push('--approval-mode', 'auto_edit');
174
- // No specific args needed for stdin input + one-shot mode
159
+ // Input via stdin
175
160
  } else if (baseCmd === 'codex') {
176
- // Codex CLI defaults to an interactive TUI, which requires a TTY.
177
- // Force non-interactive mode via `codex exec`, and feed PROMPT via stdin ("-").
178
161
  ensureCodexExec();
179
-
180
- // Write only the final message to a file to avoid parsing extra output.
181
162
  const lastMessageFile = path.join(
182
- path.dirname(promptFile),
163
+ os.tmpdir(),
183
164
  `codex-last-message-${process.pid}-${Date.now()}.txt`
184
165
  );
185
166
  args.push('--output-last-message', lastMessageFile);
186
-
187
- args.push('-');
167
+ args.push('-'); // Explicitly read from stdin
188
168
  } else {
189
- // Generic: try passing file as argument
190
- args.push(promptFile);
169
+ // Generic CLI: Fallback to temp file if not a known stdin consumer
170
+ // We assume generic tools might expect a filename as an argument.
171
+ const uniqueId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
172
+ tempPromptFile = path.join(os.tmpdir(), `asm-prompt-${uniqueId}.md`);
173
+ fs.writeFileSync(tempPromptFile, promptText);
174
+ args.push(tempPromptFile);
191
175
  }
192
176
 
193
177
  console.log(` [LLM] Running: ${baseCmd} ${args.join(' ')}`);
194
178
 
195
- // Prepare environment variables with API keys if provided
196
179
  const env = { ...process.env };
197
180
  if (apiKeys.gemini) env.GEMINI_API_KEY = apiKeys.gemini;
198
181
  if (apiKeys.anthropic) env.ANTHROPIC_API_KEY = apiKeys.anthropic;
@@ -203,10 +186,12 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
203
186
  env: env
204
187
  });
205
188
 
206
- // Feed stdin for Codex, Claude, and Gemini from the prompt file; otherwise close stdin.
207
- if (baseCmd === 'codex' || baseCmd === 'claude' || baseCmd === 'gemini') {
208
- fs.createReadStream(promptFile).pipe(child.stdin);
189
+ // Write prompt to stdin if it's a standard tool or we decided to use stdin
190
+ if (isStandardCLI) {
191
+ child.stdin.write(promptText);
192
+ child.stdin.end();
209
193
  } else {
194
+ // For generic tools using temp files, just close stdin
210
195
  child.stdin.end();
211
196
  }
212
197
 
@@ -222,6 +207,11 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
222
207
  });
223
208
 
224
209
  child.on('close', (code) => {
210
+ // Cleanup temp file if used
211
+ if (tempPromptFile && fs.existsSync(tempPromptFile)) {
212
+ try { fs.unlinkSync(tempPromptFile); } catch {}
213
+ }
214
+
225
215
  if (code === 0) {
226
216
  if (baseCmd === 'codex') {
227
217
  const outputFlagIndex = args.findIndex(a => a === '--output-last-message' || a === '-o');
@@ -247,6 +237,10 @@ async function executeCLI(command, promptFile, options = {}, apiKeys = {}) {
247
237
  });
248
238
 
249
239
  child.on('error', (err) => {
240
+ // Cleanup temp file if used
241
+ if (tempPromptFile && fs.existsSync(tempPromptFile)) {
242
+ try { fs.unlinkSync(tempPromptFile); } catch {}
243
+ }
250
244
  reject(new Error(`Failed to execute CLI: ${err.message}`));
251
245
  });
252
246
  });
@@ -259,7 +253,6 @@ async function executeAPI(provider, model, prompt, apiKey, options = {}) {
259
253
  console.log(` [LLM] Calling API: ${provider}/${model}`);
260
254
 
261
255
  if (provider === 'anthropic') {
262
- // Dynamic import to avoid requiring the package if not used
263
256
  let Anthropic;
264
257
  try {
265
258
  Anthropic = require('@anthropic-ai/sdk');
@@ -341,8 +334,9 @@ export async function llm(context, options) {
341
334
  const config = context._config || {};
342
335
  const models = config.models || {};
343
336
  const apiKeys = config.apiKeys || {};
344
- const workflowDir = config.workflowDir || process.cwd();
345
-
337
+
338
+ // No longer needed to write to prompts/ directory here
339
+
346
340
  // Look up the model command/config
347
341
  const modelConfig = models[options.model];
348
342
 
@@ -356,13 +350,9 @@ export async function llm(context, options) {
356
350
  // Build the full prompt
357
351
  const fullPrompt = buildPrompt(context, options);
358
352
 
359
- // Write to generated-prompt.md
360
- const promptFile = writeGeneratedPrompt(workflowDir, fullPrompt);
361
- console.log(` [LLM] Prompt written to: ${promptFile}`);
362
-
363
353
  // Check if it's an API call or CLI
354
+ let result;
364
355
  if (modelConfig.startsWith('api:')) {
365
- // Format: api:provider:model
366
356
  const parts = modelConfig.split(':');
367
357
  const provider = parts[1];
368
358
  const model = parts.slice(2).join(':');
@@ -375,11 +365,13 @@ export async function llm(context, options) {
375
365
  );
376
366
  }
377
367
 
378
- return executeAPI(provider, model, fullPrompt, apiKey, options);
368
+ result = await executeAPI(provider, model, fullPrompt, apiKey, options);
369
+ } else {
370
+ // CLI execution - pass fullPrompt string directly
371
+ result = await executeCLI(modelConfig, fullPrompt, options, apiKeys);
379
372
  }
380
373
 
381
- // CLI execution
382
- return executeCLI(modelConfig, promptFile, options, apiKeys);
374
+ return { ...result, fullPrompt };
383
375
  }
384
376
 
385
377
  /**
@@ -446,7 +438,7 @@ export function parseJSON(text) {
446
438
  } catch {}
447
439
  }
448
440
 
449
- const arrayMatch = text.match(/\[[\s\S]*\]/);
441
+ const arrayMatch = text.match(/\{[\[\s\S]*\]\}/);
450
442
  if (arrayMatch) {
451
443
  try {
452
444
  return JSON.parse(arrayMatch[0]);
@@ -469,4 +461,4 @@ export async function llmJSON(context, options) {
469
461
  ...response,
470
462
  data: parseJSON(response.text)
471
463
  };
472
- }
464
+ }
@@ -34,11 +34,18 @@ export async function agent(name, params = {}) {
34
34
  try {
35
35
  const result = await executeAgent(runtime, name, params);
36
36
 
37
+ let prompt = undefined;
38
+ if (result && typeof result === 'object' && result._debug_prompt) {
39
+ prompt = result._debug_prompt;
40
+ delete result._debug_prompt;
41
+ }
42
+
37
43
  console.log(` [Agent: ${name}] Completed`);
38
44
  runtime.prependHistory({
39
45
  event: 'AGENT_COMPLETED',
40
46
  agent: name,
41
- output: result
47
+ output: result,
48
+ prompt: prompt
42
49
  });
43
50
 
44
51
  return result;
@@ -243,11 +250,11 @@ async function executeMDAgent(runtime, agentPath, name, params) {
243
250
  });
244
251
 
245
252
  // Return the user's response as the agent result
246
- return { [outputKey]: userResponse };
253
+ return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
247
254
  }
248
255
 
249
256
  // Return result object
250
- return { [outputKey]: output };
257
+ return { [outputKey]: output, _debug_prompt: response.fullPrompt };
251
258
  }
252
259
 
253
260
  /**
@@ -38,7 +38,6 @@ export class WorkflowRuntime {
38
38
  this.agentsDir = path.join(workflowDir, 'agents');
39
39
  this.interactionsDir = path.join(workflowDir, 'interactions');
40
40
  this.steeringDir = path.join(workflowDir, 'steering');
41
- this.generatedPromptFile = path.join(this.stateDir, 'generated-prompt.md');
42
41
  this.historyFile = path.join(this.stateDir, 'history.jsonl');
43
42
  this.stateFile = path.join(this.stateDir, 'current.json');
44
43
 
@@ -361,11 +360,6 @@ export class WorkflowRuntime {
361
360
  fs.unlinkSync(this.historyFile);
362
361
  }
363
362
 
364
- // 2. Delete generated-prompt.md file
365
- if (fs.existsSync(this.generatedPromptFile)) {
366
- fs.unlinkSync(this.generatedPromptFile);
367
- }
368
-
369
363
  // 3. Clear interactions directory
370
364
  if (fs.existsSync(this.interactionsDir)) {
371
365
  fs.rmSync(this.interactionsDir, { recursive: true, force: true });
package/lib/setup.js CHANGED
@@ -173,7 +173,7 @@ Once you have it create a yoda-greeting.md file in root dir with the greeting.
173
173
  You are a fast, direct worker. Do NOT investigate the codebase or read files unless strictly necessary. Perform the requested action immediately using the provided context. Avoid "thinking" steps or creating plans if the task is simple.
174
174
  `;
175
175
 
176
- const yodaNameCollectorAgent = `---
176
+ const yodaNameCollectorAgent = `---
177
177
  model: low
178
178
  output: name
179
179
  ---
@@ -288,22 +288,17 @@ ${workflowName}/
288
288
  ├── package.json # Sets "type": "module" for this workflow folder
289
289
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
290
290
  ├── interactions/ # Human-in-the-loop inputs (created at runtime)
291
- ├── state/ # Runtime state (current.json, history.jsonl)
291
+ ├── state/ # Runtime state (current.json, history.jsonl, prompts/)
292
292
  └── steering/ # Steering configuration
293
293
  \\\`\\\`\\\`
294
294
 
295
295
  ## Usage
296
296
 
297
- Run the workflow:
297
+ Run the workflow (or resume if interrupted):
298
298
  \\\`\\\`\\\`bash
299
299
  state-machine run ${workflowName}
300
300
  \\\`\\\`\\\`
301
301
 
302
- Resume a paused workflow:
303
- \\\`\\\`\\\`bash
304
- state-machine resume ${workflowName}
305
- \\\`\\\`\\\`
306
-
307
302
  Check status:
308
303
  \\\`\\\`\\\`bash
309
304
  state-machine status ${workflowName}
@@ -314,11 +309,21 @@ View history:
314
309
  state-machine history ${workflowName}
315
310
  \\\`\\\`\\\`
316
311
 
317
- Reset state:
312
+ View trace logs in browser with live updates:
313
+ \\\`\\\`\\\`bash
314
+ state-machine follow ${workflowName}
315
+ \\\`\\\`\\\`
316
+
317
+ Reset state (clears memory/state):
318
318
  \\\`\\\`\\\`bash
319
319
  state-machine reset ${workflowName}
320
320
  \\\`\\\`\\\`
321
321
 
322
+ Hard reset (clears everything: history/interactions/memory):
323
+ \\\`\\\`\\\`bash
324
+ state-machine reset-hard ${workflowName}
325
+ \\\`\\\`\\\`
326
+
322
327
  ## Writing Workflows
323
328
 
324
329
  Edit \`workflow.js\` - write normal async JavaScript:
@@ -0,0 +1,324 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Workflow Prompts Viewer</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ darkMode: 'class',
11
+ }
12
+ </script>
13
+ <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
14
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
15
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
16
+ <style>
17
+ .markdown-body { white-space: pre-wrap; font-family: monospace; }
18
+ /* Scrollbar styles for dark mode */
19
+ .dark ::-webkit-scrollbar { width: 10px; height: 10px; }
20
+ .dark ::-webkit-scrollbar-track { background: #000000; }
21
+ .dark ::-webkit-scrollbar-thumb { background: #27272a; border-radius: 5px; }
22
+ .dark ::-webkit-scrollbar-thumb:hover { background: #3f3f46; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div id="root"></div>
27
+
28
+ <script type="text/babel">
29
+ const { useState, useEffect } = React;
30
+
31
+ // Icons
32
+ const SunIcon = () => (
33
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
34
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
35
+ </svg>
36
+ );
37
+
38
+ const MoonIcon = () => (
39
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
40
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
41
+ </svg>
42
+ );
43
+
44
+ const CopyIcon = () => (
45
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
46
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
47
+ </svg>
48
+ );
49
+
50
+ const CheckIcon = () => (
51
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
52
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
53
+ </svg>
54
+ );
55
+
56
+ function CopyButton({ text, className }) {
57
+ const [copied, setCopied] = useState(false);
58
+
59
+ const handleCopy = () => {
60
+ const content = typeof text === 'object' ? JSON.stringify(text, null, 2) : text;
61
+ navigator.clipboard.writeText(content);
62
+ setCopied(true);
63
+ setTimeout(() => setCopied(false), 2000);
64
+ };
65
+
66
+ return (
67
+ <button
68
+ onClick={handleCopy}
69
+ className={`flex items-center space-x-1 text-[9px] uppercase tracking-wider transition-colors hover:text-blue-500 focus:outline-none ${className}`}
70
+ title="Copy to clipboard"
71
+ >
72
+ {copied ? <CheckIcon /> : <CopyIcon />}
73
+ <span>{copied ? 'Copied' : 'Copy'}</span>
74
+ </button>
75
+ );
76
+ }
77
+
78
+ function App() {
79
+ const [history, setHistory] = useState([]);
80
+ const [loading, setLoading] = useState(true);
81
+ const [error, setError] = useState(null);
82
+ const [workflowName, setWorkflowName] = useState('');
83
+ const [theme, setTheme] = useState('dark');
84
+ const [sortOrder, setSortOrder] = useState('newest'); // 'newest' | 'oldest'
85
+
86
+ useEffect(() => {
87
+ const fetchData = () => {
88
+ fetch('/api/history')
89
+ .then(res => res.json())
90
+ .then(data => {
91
+ setHistory(data.entries);
92
+ setWorkflowName(data.workflowName);
93
+ setLoading(false);
94
+ })
95
+ .catch(err => {
96
+ setError(err.message);
97
+ setLoading(false);
98
+ });
99
+ };
100
+
101
+ // Initial fetch
102
+ fetchData();
103
+
104
+ // Setup SSE
105
+ const eventSource = new EventSource('/api/events');
106
+ eventSource.onmessage = (event) => {
107
+ if (event.data === 'update') {
108
+ fetchData();
109
+ }
110
+ };
111
+
112
+ return () => {
113
+ eventSource.close();
114
+ };
115
+ }, []);
116
+
117
+ const toggleTheme = () => {
118
+ setTheme(prev => prev === 'dark' ? 'light' : 'dark');
119
+ };
120
+
121
+ const toggleSort = () => {
122
+ setSortOrder(prev => prev === 'newest' ? 'oldest' : 'newest');
123
+ };
124
+
125
+ if (loading) return (
126
+ <div className={theme}>
127
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-gray-500 dark:text-zinc-500">
128
+ Loading history...
129
+ </div>
130
+ </div>
131
+ );
132
+
133
+ if (error) return (
134
+ <div className={theme}>
135
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-red-500">
136
+ Error: {error}
137
+ </div>
138
+ </div>
139
+ );
140
+
141
+ // Filter for events we want to display
142
+ let visibleEvents = history.filter(item =>
143
+ [
144
+ 'WORKFLOW_STARTED', 'WORKFLOW_COMPLETED', 'WORKFLOW_FAILED', 'WORKFLOW_RESET',
145
+ 'AGENT_STARTED', 'AGENT_COMPLETED', 'AGENT_FAILED',
146
+ 'INTERACTION_REQUESTED', 'INTERACTION_RESOLVED'
147
+ ].includes(item.event)
148
+ );
149
+
150
+ // Apply Sort
151
+ // History from API is "Newest First" (index 0 is latest)
152
+ if (sortOrder === 'oldest') {
153
+ visibleEvents = [...visibleEvents].reverse();
154
+ }
155
+
156
+ const formatTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
157
+
158
+ return (
159
+ <div className={theme}>
160
+ <div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-200">
161
+ <div className="max-w-5xl mx-auto min-h-screen flex flex-col">
162
+
163
+ {/* Sticky Header */}
164
+ <header className="sticky top-0 z-50 py-4 px-6 bg-gray-50/90 dark:bg-black/90 backdrop-blur-md border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between mb-8 transition-colors">
165
+ <div className="flex-1">
166
+ <h1 className="text-xl font-bold text-gray-800 dark:text-zinc-100 transition-colors uppercase tracking-tight">{workflowName}</h1>
167
+ <p className="text-gray-500 dark:text-zinc-500 text-xs mt-0.5">Runtime History & Prompt Logs</p>
168
+ </div>
169
+ <div className="flex items-center space-x-2">
170
+ <button
171
+ onClick={toggleSort}
172
+ className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
173
+ title={sortOrder === 'newest' ? "Sort: Newest First" : "Sort: Oldest First"}
174
+ >
175
+ {sortOrder === 'newest' ?
176
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" /></svg>
177
+ :
178
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" transform="scale(1, -1) translate(0, -24)" /></svg>
179
+ }
180
+ </button>
181
+ <button
182
+ onClick={toggleTheme}
183
+ className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
184
+ title="Toggle Theme"
185
+ >
186
+ {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
187
+ </button>
188
+ </div>
189
+ </header>
190
+
191
+ {/* Content */}
192
+ <div className="flex-1 space-y-8 px-6">
193
+ {visibleEvents.length === 0 && (
194
+ <div className="text-center text-gray-400 py-10">No execution history found.</div>
195
+ )}
196
+
197
+ {visibleEvents.map((item, idx) => {
198
+ // 1. Lifecycle Events
199
+ if (item.event.startsWith('WORKFLOW_')) {
200
+ const colorMap = {
201
+ 'WORKFLOW_STARTED': 'text-green-500 dark:text-green-400',
202
+ 'WORKFLOW_COMPLETED': 'text-blue-500 dark:text-blue-400',
203
+ 'WORKFLOW_FAILED': 'text-red-500 dark:text-red-400',
204
+ 'WORKFLOW_RESET': 'text-yellow-500 dark:text-yellow-400'
205
+ };
206
+ return (
207
+ <div key={idx} className="flex flex-col items-center py-4">
208
+ <div className="flex items-center space-x-3 text-[10px] uppercase tracking-[0.2em] font-bold text-zinc-400 dark:text-zinc-600">
209
+ <div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
210
+ <span className={colorMap[item.event]}>{item.event.replace('_', ' ')}</span>
211
+ <span>{formatTime(item.timestamp)}</span>
212
+ <div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
213
+ </div>
214
+ {item.error && <div className="mt-2 text-red-500 text-xs font-mono">{item.error}</div>}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ // 2. Agent Started
220
+ if (item.event === 'AGENT_STARTED') {
221
+ return (
222
+ <div key={idx} className="flex justify-start">
223
+ <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest flex items-center space-x-2">
224
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-500/50 animate-pulse"></span>
225
+ <span>Agent <span className="text-zinc-600 dark:text-zinc-400">{item.agent}</span> started</span>
226
+ <span>&bull;</span>
227
+ <span>{formatTime(item.timestamp)}</span>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ // 3. Agent Failed
234
+ if (item.event === 'AGENT_FAILED') {
235
+ return (
236
+ <div key={idx} className="flex justify-center">
237
+ <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-lg px-4 py-2 text-red-600 dark:text-red-400 text-xs font-mono w-full max-w-2xl">
238
+ <div className="font-bold mb-1 underline">AGENT FAILED: {item.agent}</div>
239
+ <div>{item.error}</div>
240
+ </div>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ // 4. Interaction Requested
246
+ if (item.event === 'INTERACTION_REQUESTED') {
247
+ return (
248
+ <div key={idx} className="flex justify-center">
249
+ <div className="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-lg px-6 py-4 text-center max-w-md w-full">
250
+ <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-bold tracking-widest mb-1">Human Intervention Needed</div>
251
+ <div className="text-xs text-zinc-600 dark:text-zinc-400 italic">Waiting for response to "{item.slug}"...</div>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ // 5. Agent Completed / Interaction Resolved (The Bubbles)
258
+ if (item.event === 'AGENT_COMPLETED' || item.event === 'INTERACTION_RESOLVED') {
259
+ return (
260
+ <div key={idx} className="flex flex-col space-y-4">
261
+ {/* Header Line */}
262
+ <div className="flex items-center justify-center space-x-2 text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest">
263
+ <span className="font-black text-zinc-500 dark:text-zinc-400">{item.agent || item.slug}</span>
264
+ <span>&bull;</span>
265
+ <span>COMPLETED</span>
266
+ <span>&bull;</span>
267
+ <span>{formatTime(item.timestamp)}</span>
268
+ </div>
269
+
270
+ {/* Output (Response) - NOW ON TOP */}
271
+ {(item.output || item.result) && (
272
+ <div className="flex justify-end w-full group">
273
+ <div className="max-w-[85%] bg-blue-50 dark:bg-blue-950/20 border border-blue-100 dark:border-blue-900/40 rounded-2xl rounded-tr-none shadow-sm p-6 transition-all hover:border-blue-200 dark:hover:border-blue-800 relative">
274
+ <div className="flex justify-between items-center mb-3">
275
+ <div className="text-[9px] font-black text-blue-300 dark:text-blue-800/60 uppercase tracking-[0.2em] text-right w-full">Output / Response</div>
276
+ <div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
277
+ <CopyButton text={item.output || item.result} className="text-blue-400 hover:text-blue-600 dark:text-blue-600 dark:hover:text-blue-400" />
278
+ </div>
279
+ </div>
280
+ <div className="markdown-body text-gray-800 dark:text-zinc-200 text-sm overflow-x-auto leading-relaxed">
281
+ {typeof item.output === 'object' ? JSON.stringify(item.output, null, 2) : (item.output || item.result)}
282
+ </div>
283
+ </div>
284
+ </div>
285
+ )}
286
+
287
+ {/* Prompt (Input) - NOW ON BOTTOM */}
288
+ {item.prompt && (
289
+ <div className="flex justify-start w-full group">
290
+ <div className="max-w-[85%] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl rounded-tl-none shadow-sm p-6 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 relative">
291
+ <div className="flex justify-between items-center mb-3">
292
+ <div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.2em]">Prompt / Input</div>
293
+ <div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
294
+ <CopyButton text={item.prompt} className="text-gray-400 hover:text-gray-600 dark:text-zinc-600 dark:hover:text-zinc-400" />
295
+ </div>
296
+ </div>
297
+ <div className="markdown-body text-gray-800 dark:text-zinc-300 text-sm overflow-x-auto leading-relaxed">
298
+ {item.prompt}
299
+ </div>
300
+ </div>
301
+ </div>
302
+ )}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ return null;
308
+ })}
309
+ </div>
310
+
311
+ <footer className="mt-20 mb-8 text-center text-zinc-400 dark:text-zinc-800 text-[10px] uppercase tracking-[0.3em] transition-colors">
312
+ Agent State Machine &bull; Debug Terminal
313
+ </footer>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ );
318
+ }
319
+
320
+ const root = ReactDOM.createRoot(document.getElementById('root'));
321
+ root.render(<App />);
322
+ </script>
323
+ </body>
324
+ </html>
@@ -0,0 +1,150 @@
1
+ /**
2
+ * File: /lib/ui/server.js
3
+ */
4
+
5
+ import http from 'http';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ export function startServer(workflowDir, initialPort = 3000) {
14
+ const clients = new Set();
15
+ const stateDir = path.join(workflowDir, 'state');
16
+
17
+ // Watch for changes in the state directory
18
+ // We debounce slightly to avoid sending multiple events for a single write burst
19
+ let debounceTimer;
20
+ const broadcastUpdate = () => {
21
+ if (debounceTimer) clearTimeout(debounceTimer);
22
+ debounceTimer = setTimeout(() => {
23
+ const msg = 'data: update\n\n';
24
+ for (const client of clients) {
25
+ try {
26
+ client.write(msg);
27
+ } catch (e) {
28
+ clients.delete(client);
29
+ }
30
+ }
31
+ }, 100);
32
+ };
33
+
34
+ try {
35
+ if (fs.existsSync(stateDir)) {
36
+ fs.watch(stateDir, (eventType, filename) => {
37
+ if (filename && (filename === 'history.jsonl' || filename.startsWith('history'))) {
38
+ broadcastUpdate();
39
+ }
40
+ });
41
+ } else {
42
+ console.warn('Warning: State directory does not exist yet. Live updates might not work until it is created.');
43
+ }
44
+ } catch (err) {
45
+ console.warn('Warning: Failed to setup file watcher:', err.message);
46
+ }
47
+
48
+ // Request Handler
49
+ const requestHandler = (req, res) => {
50
+ // Serve the main HTML page
51
+ if (req.url === '/' || req.url === '/index.html') {
52
+ const htmlPath = path.join(__dirname, 'index.html');
53
+ fs.readFile(htmlPath, (err, content) => {
54
+ if (err) {
55
+ res.writeHead(500);
56
+ res.end('Error loading UI');
57
+ return;
58
+ }
59
+ res.writeHead(200, { 'Content-Type': 'text/html' });
60
+ res.end(content);
61
+ });
62
+ return;
63
+ }
64
+
65
+ // Server-Sent Events endpoint
66
+ if (req.url === '/api/events') {
67
+ res.writeHead(200, {
68
+ 'Content-Type': 'text/event-stream',
69
+ 'Cache-Control': 'no-cache',
70
+ 'Connection': 'keep-alive',
71
+ });
72
+ res.write('retry: 10000\n\n');
73
+
74
+ clients.add(res);
75
+
76
+ req.on('close', () => {
77
+ clients.delete(res);
78
+ });
79
+ return;
80
+ }
81
+
82
+ // Serve API
83
+ if (req.url === '/api/history') {
84
+ const historyFile = path.join(stateDir, 'history.jsonl');
85
+
86
+ if (!fs.existsSync(historyFile)) {
87
+ res.writeHead(200, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({
89
+ workflowName: path.basename(workflowDir),
90
+ entries: []
91
+ }));
92
+ return;
93
+ }
94
+
95
+ try {
96
+ const fileContent = fs.readFileSync(historyFile, 'utf-8');
97
+ const lines = fileContent.trim().split('\n');
98
+ const entries = lines
99
+ .map(line => {
100
+ try { return JSON.parse(line); } catch { return null; }
101
+ })
102
+ .filter(Boolean);
103
+
104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({
106
+ workflowName: path.basename(workflowDir),
107
+ entries
108
+ }));
109
+ } catch (err) {
110
+ res.writeHead(500, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ error: err.message }));
112
+ }
113
+ return;
114
+ }
115
+
116
+ // 404
117
+ res.writeHead(404);
118
+ res.end('Not found');
119
+ };
120
+
121
+ // Port hunting logic
122
+ let port = initialPort;
123
+ const maxPort = initialPort + 100; // Try up to 100 ports
124
+
125
+ const attemptServer = () => {
126
+ const server = http.createServer(requestHandler);
127
+
128
+ server.on('error', (e) => {
129
+ if (e.code === 'EADDRINUSE') {
130
+ if (port < maxPort) {
131
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
132
+ port++;
133
+ attemptServer();
134
+ } else {
135
+ console.error(`Error: Could not find an open port between ${initialPort} and ${maxPort}.`);
136
+ }
137
+ } else {
138
+ console.error('Server error:', e);
139
+ }
140
+ });
141
+
142
+ server.listen(port, () => {
143
+ console.log(`\n> Follow UI running at http://localhost:${port}`);
144
+ console.log(`> Viewing history for: ${workflowDir}`);
145
+ console.log(`> Press Ctrl+C to stop`);
146
+ });
147
+ };
148
+
149
+ attemptServer();
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "A workflow orchestrator for running agents and scripts in sequence with state management",
6
6
  "main": "lib/index.js",