@miphamai/cli 0.4.0 → 0.5.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.
Files changed (56) hide show
  1. package/bin/mipham.ts +186 -0
  2. package/package.json +1 -1
  3. package/src/agent/agent-context.ts +56 -0
  4. package/src/agent/agent-registry.ts +134 -0
  5. package/src/agent/sub-agent.ts +73 -88
  6. package/src/agent/types.ts +39 -0
  7. package/src/agent-view/agent-view-manager.ts +217 -0
  8. package/src/agent-view/dashboard.tsx +218 -0
  9. package/src/agent-view/session-peek.tsx +72 -0
  10. package/src/agent-view/session-row.tsx +70 -0
  11. package/src/artifacts/server.ts +31 -0
  12. package/src/artifacts/versioning.ts +127 -0
  13. package/src/core/context-compact.ts +50 -0
  14. package/src/core/context-drain.ts +54 -0
  15. package/src/core/context-microcompact.ts +132 -0
  16. package/src/core/context-snip.ts +74 -0
  17. package/src/core/context-token.ts +108 -0
  18. package/src/core/context.ts +169 -0
  19. package/src/core/engine.ts +144 -3
  20. package/src/core/hooks-config.ts +52 -0
  21. package/src/core/hooks-executor.ts +113 -0
  22. package/src/core/hooks.ts +90 -30
  23. package/src/core/instructions.ts +11 -0
  24. package/src/core/memory/memory-loader.ts +23 -0
  25. package/src/core/memory/memory-manager.ts +192 -0
  26. package/src/core/memory/memory-writer.ts +72 -0
  27. package/src/core/permission-config.ts +34 -0
  28. package/src/core/permission-rules.ts +66 -0
  29. package/src/core/permission.ts +221 -36
  30. package/src/index.tsx +20 -0
  31. package/src/plugin/plugin-loader.ts +36 -0
  32. package/src/plugin/plugin-manager.ts +125 -0
  33. package/src/plugin/plugin-validator.ts +41 -0
  34. package/src/shared/types.ts +61 -1
  35. package/src/skills/fork-executor.ts +40 -0
  36. package/src/skills/loader.ts +46 -0
  37. package/src/tools/agent/agent.ts +20 -11
  38. package/src/tools/agent/skill.ts +32 -2
  39. package/src/tools/computer/app-launcher.ts +39 -0
  40. package/src/tools/computer/browser.ts +48 -0
  41. package/src/tools/computer/computer-use.ts +88 -0
  42. package/src/tools/computer/playwright.d.ts +7 -0
  43. package/src/tools/computer/screenshot.ts +57 -0
  44. package/src/tools/index.ts +3 -0
  45. package/src/ui/app.tsx +10 -1
  46. package/src/ui/commands.ts +99 -30
  47. package/src/ui/input.tsx +123 -8
  48. package/src/ui/vim-motions.ts +96 -0
  49. package/src/workflow/budget.ts +33 -0
  50. package/src/workflow/journal.ts +105 -0
  51. package/src/workflow/primitives/agent.ts +51 -0
  52. package/src/workflow/primitives/parallel.ts +8 -0
  53. package/src/workflow/primitives/phase.ts +19 -0
  54. package/src/workflow/primitives/pipeline.ts +24 -0
  55. package/src/workflow/runtime.ts +87 -0
  56. package/src/workflow/sandbox.ts +75 -0
package/src/ui/input.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useRef } from 'react'
2
- import { Box, Text } from 'ink'
2
+ import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
+ import { VimMotionEngine, type VimMode } from './vim-motions.js'
4
5
 
5
6
  interface InputBarProps {
6
7
  onSubmit: (input: string) => void
@@ -137,6 +138,110 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
137
138
  prevLoading.current = isLoading
138
139
  }, [isLoading])
139
140
 
141
+ const [vimMode, setVimMode] = useState<VimMode>('insert')
142
+ // NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
143
+ // placeholder); engine mode (vimEngine.current.mode) drives logic inside useInput so
144
+ // the handler always reads the authoritative mode without stale-closure risk.
145
+ const vimEngine = useRef(new VimMotionEngine())
146
+ const vimPending = useRef<string | null>(null)
147
+ const [searchMode, setSearchMode] = useState(false)
148
+ const [searchQuery, setSearchQuery] = useState('')
149
+
150
+ // ── Vim motions: intercept keys in normal mode ──
151
+
152
+ useInput((input, key) => {
153
+ // Escape toggles between insert and normal mode
154
+ if (key.escape) {
155
+ // Cancel search mode if active
156
+ if (searchMode) {
157
+ setSearchMode(false)
158
+ setSearchQuery('')
159
+ return
160
+ }
161
+ // Clear any pending multi-key sequence
162
+ if (vimPending.current) {
163
+ vimPending.current = null
164
+ }
165
+ setVimMode((prev) => (prev === 'insert' ? 'normal' : 'insert'))
166
+ vimEngine.current.mode = vimEngine.current.mode === 'insert' ? 'normal' : 'insert'
167
+ return
168
+ }
169
+
170
+ if (vimEngine.current.mode !== 'normal') return
171
+
172
+ // Handle search mode — collect query characters
173
+ if (searchMode) {
174
+ if (key.return) {
175
+ const action = vimEngine.current.handleSearch(value, searchQuery)
176
+ if (action.text !== undefined) setValue(action.text)
177
+ // NOTE: action.cursor is not settable on ink-text-input — user repositions manually
178
+ setSearchMode(false)
179
+ setSearchQuery('')
180
+ return
181
+ }
182
+ if (key.backspace || key.delete) {
183
+ setSearchQuery((q) => q.slice(0, -1))
184
+ return
185
+ }
186
+ // Accumulate printable characters
187
+ if (input && input.length === 1 && !key.escape && !key.return) {
188
+ setSearchQuery((q) => q + input)
189
+ }
190
+ return
191
+ }
192
+
193
+ // Handle pending two-key sequences (dd, yy)
194
+ if (vimPending.current !== null) {
195
+ if (vimPending.current === 'd' && input === 'd') {
196
+ const action = vimEngine.current.handleDD(value)
197
+ setValue(action.text ?? value)
198
+ } else if (vimPending.current === 'y' && input === 'y') {
199
+ vimEngine.current.handleYY(value)
200
+ }
201
+ // Always clear pending — even when second key doesn't match
202
+ vimPending.current = null
203
+ return
204
+ }
205
+
206
+ // Handle p (paste) — pastes clipboard at cursor position
207
+ if (input === 'p') {
208
+ const action = vimEngine.current.handlePaste(value, value.length)
209
+ if (action.text !== undefined) setValue(action.text)
210
+ return
211
+ }
212
+
213
+ // Handle u (undo)
214
+ if (input === 'u') {
215
+ const action = vimEngine.current.handleUndo(value)
216
+ if (action.text !== undefined) setValue(action.text)
217
+ return
218
+ }
219
+
220
+ // Handle / (enter search mode)
221
+ if (input === '/') {
222
+ setSearchMode(true)
223
+ setSearchQuery('')
224
+ return
225
+ }
226
+
227
+ // Handle single-key motions (h, j, k, l, w, b, 0, $, d, y)
228
+ const action = vimEngine.current.handleNormal(input, value, value.length)
229
+ if (!action) return
230
+
231
+ if (action.pending) {
232
+ vimPending.current = action.pending
233
+ return
234
+ }
235
+
236
+ if (action.text !== undefined) {
237
+ setValue(action.text)
238
+ }
239
+
240
+ // NOTE: action.cursor is returned by motions (h/j/k/l/w/b/0/$) but
241
+ // ink-text-input does not expose a programmatic cursor-position API.
242
+ // The cursor hint is informational only; the user repositions manually.
243
+ })
244
+
140
245
  const handleSubmit = (val: string) => {
141
246
  if (!val.trim() || isLoading) return
142
247
  onSubmit(val)
@@ -146,18 +251,28 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
146
251
  return (
147
252
  <Box marginTop={1}>
148
253
  <Box marginRight={1}>
149
- <Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
254
+ <Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
255
+ {vimMode === 'normal' ? ':' : '>'}
256
+ </Text>
150
257
  </Box>
151
258
  <TextInput
152
259
  value={value}
153
- onChange={setValue}
260
+ onChange={(val) => {
261
+ // Block text changes during search mode — keys go to search query
262
+ if (searchMode) return
263
+ setValue(val)
264
+ }}
154
265
  onSubmit={handleSubmit}
155
266
  placeholder={
156
- isLoading
157
- ? `${verb}...`
158
- : completionVerb
159
- ? completionVerb
160
- : 'Type a message (Esc to cancel)...'
267
+ searchMode
268
+ ? `/${searchQuery}`
269
+ : vimMode === 'normal'
270
+ ? '[NORMAL] h/j/k/l w/b 0/$ dd yy p u / (Esc to insert)'
271
+ : isLoading
272
+ ? `${verb}...`
273
+ : completionVerb
274
+ ? completionVerb
275
+ : 'Type a message (Esc to cancel)...'
161
276
  }
162
277
  />
163
278
  </Box>
@@ -0,0 +1,96 @@
1
+ export type VimMode = 'insert' | 'normal'
2
+
3
+ export class VimMotionEngine {
4
+ mode: VimMode = 'insert'
5
+ private clipboard = ''
6
+ private undoStack: string[] = []
7
+
8
+ /** Handle a keypress in normal mode. Returns new cursor position delta and any text mutation. */
9
+ handleNormal(key: string, text: string, cursor: number): VimAction | null {
10
+ switch (key) {
11
+ case 'h':
12
+ return { cursor: cursor - 1 }
13
+ case 'j':
14
+ return { cursor: text.length } // end of line (like $)
15
+ case 'k':
16
+ return { cursor: 0 } // start of line (like 0)
17
+ case 'l':
18
+ return { cursor: cursor + 1 }
19
+ case '0':
20
+ return { cursor: 0 }
21
+ case '$':
22
+ return { cursor: text.length }
23
+ case 'w':
24
+ return { cursor: this.nextWord(text, cursor) }
25
+ case 'b':
26
+ return { cursor: this.prevWord(text, cursor) }
27
+ case 'd':
28
+ return { pending: 'd' }
29
+ case 'y':
30
+ return { pending: 'y' }
31
+ default:
32
+ return null
33
+ }
34
+ }
35
+
36
+ /** Handle dd — delete entire line */
37
+ handleDD(text: string): VimAction {
38
+ this.clipboard = text
39
+ this.pushUndo(text)
40
+ return { text: '', cursor: 0 }
41
+ }
42
+
43
+ /** Handle yy — yank entire line */
44
+ handleYY(text: string): VimAction {
45
+ this.clipboard = text
46
+ return { cursor: 0 } // no change
47
+ }
48
+
49
+ /** Handle p — paste after cursor */
50
+ handlePaste(text: string, cursor: number): VimAction {
51
+ const newText = text.slice(0, cursor) + this.clipboard + text.slice(cursor)
52
+ this.pushUndo(text)
53
+ return { text: newText, cursor: cursor + this.clipboard.length }
54
+ }
55
+
56
+ /** Handle u — undo */
57
+ handleUndo(text: string): VimAction {
58
+ const prev = this.undoStack.pop()
59
+ if (prev !== undefined) {
60
+ return { text: prev, cursor: prev.length }
61
+ }
62
+ return { cursor: text.length }
63
+ }
64
+
65
+ /** Handle /search */
66
+ handleSearch(text: string, query: string): VimAction {
67
+ const idx = text.indexOf(query)
68
+ return idx >= 0 ? { cursor: idx } : { cursor: 0 }
69
+ }
70
+
71
+ private nextWord(text: string, cursor: number): number {
72
+ // Skip word characters, then skip whitespace
73
+ let i = cursor
74
+ while (i < text.length && text[i] !== ' ') i++
75
+ while (i < text.length && text[i] === ' ') i++
76
+ return i
77
+ }
78
+
79
+ private prevWord(text: string, cursor: number): number {
80
+ let i = cursor - 1
81
+ while (i > 0 && text[i] === ' ') i--
82
+ while (i > 0 && text[i] !== ' ') i--
83
+ return i > 0 ? i + 1 : 0
84
+ }
85
+
86
+ private pushUndo(text: string): void {
87
+ this.undoStack.push(text)
88
+ if (this.undoStack.length > 50) this.undoStack.shift()
89
+ }
90
+ }
91
+
92
+ export interface VimAction {
93
+ text?: string
94
+ cursor?: number
95
+ pending?: string
96
+ }
@@ -0,0 +1,33 @@
1
+ export interface Budget {
2
+ total: number | null
3
+ spent(): number
4
+ remaining(): number
5
+ consume(tokens: number): void
6
+ }
7
+
8
+ /**
9
+ * Create a token budget tracker.
10
+ * If totalTokens is null, the budget is unlimited.
11
+ */
12
+ export function createBudget(totalTokens: number | null): Budget {
13
+ let spent = 0
14
+
15
+ return {
16
+ total: totalTokens,
17
+
18
+ spent(): number {
19
+ return spent
20
+ },
21
+
22
+ remaining(): number {
23
+ return totalTokens === null ? Infinity : Math.max(0, totalTokens - spent)
24
+ },
25
+
26
+ consume(tokens: number): void {
27
+ spent += tokens
28
+ if (totalTokens !== null && spent >= totalTokens) {
29
+ throw new Error('Token budget exceeded')
30
+ }
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ readFileSync,
5
+ existsSync,
6
+ appendFileSync,
7
+ readdirSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+
12
+ const WORKFLOW_DIR = join(homedir(), '.mipham', 'workflows')
13
+
14
+ export interface JournalEntry {
15
+ seq: number
16
+ type: 'agent' | 'phase' | 'log'
17
+ prompt?: string
18
+ opts?: Record<string, unknown>
19
+ result?: unknown
20
+ message?: string
21
+ }
22
+
23
+ export interface JournalState {
24
+ seq: number
25
+ phases: string[]
26
+ }
27
+
28
+ /**
29
+ * Create a journal for a workflow run.
30
+ * Returns the path to the run directory.
31
+ */
32
+ export function createJournal(runId: string, script: string): string {
33
+ const dir = join(WORKFLOW_DIR, runId)
34
+ mkdirSync(dir, { recursive: true })
35
+
36
+ writeFileSync(join(dir, 'script.js'), script, 'utf-8')
37
+ writeFileSync(join(dir, 'journal.jsonl'), '', 'utf-8')
38
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }), 'utf-8')
39
+
40
+ return dir
41
+ }
42
+
43
+ /**
44
+ * Append an agent call to the journal. Returns the new sequence number.
45
+ */
46
+ export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>): number {
47
+ const dir = join(WORKFLOW_DIR, runId)
48
+ const state = readState(runId)
49
+
50
+ const seq = state.seq + 1
51
+ const fullEntry: JournalEntry = { seq, ...entry }
52
+
53
+ appendFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n', 'utf-8')
54
+
55
+ state.seq = seq
56
+ writeFileSync(join(dir, 'state.json'), JSON.stringify(state), 'utf-8')
57
+
58
+ return seq
59
+ }
60
+
61
+ /**
62
+ * Load all journal entries for a run. Returns empty array if run not found.
63
+ */
64
+ export function loadJournal(runId: string): JournalEntry[] {
65
+ const journalPath = join(WORKFLOW_DIR, runId, 'journal.jsonl')
66
+ if (!existsSync(journalPath)) return []
67
+
68
+ const raw = readFileSync(journalPath, 'utf-8')
69
+ return raw
70
+ .split('\n')
71
+ .filter((line) => line.trim())
72
+ .map((line) => JSON.parse(line) as JournalEntry)
73
+ }
74
+
75
+ function readState(runId: string): JournalState {
76
+ const statePath = join(WORKFLOW_DIR, runId, 'state.json')
77
+ if (!existsSync(statePath)) return { seq: 0, phases: [] }
78
+ return JSON.parse(readFileSync(statePath, 'utf-8'))
79
+ }
80
+
81
+ /**
82
+ * Load the saved script for a run. Returns empty string if not found.
83
+ */
84
+ export function loadScript(runId: string): string {
85
+ const scriptPath = join(WORKFLOW_DIR, runId, 'script.js')
86
+ if (!existsSync(scriptPath)) return ''
87
+ return readFileSync(scriptPath, 'utf-8')
88
+ }
89
+
90
+ /**
91
+ * List all workflow run IDs.
92
+ */
93
+ export function listRuns(): string[] {
94
+ if (!existsSync(WORKFLOW_DIR)) return []
95
+ return readdirSync(WORKFLOW_DIR, { withFileTypes: true })
96
+ .filter((d) => d.isDirectory())
97
+ .map((d) => d.name)
98
+ }
99
+
100
+ /**
101
+ * Get the journal directory path for a run.
102
+ */
103
+ export function getRunDir(runId: string): string {
104
+ return join(WORKFLOW_DIR, runId)
105
+ }
@@ -0,0 +1,51 @@
1
+ import { SubAgent } from '../../agent/sub-agent'
2
+ import type { ProviderRegistry } from '../../providers/registry'
3
+ import type { ToolDefinition } from '../../shared/index.ts'
4
+
5
+ export interface WorkflowAgentOpts {
6
+ label?: string
7
+ phase?: string
8
+ schema?: object
9
+ model?: string
10
+ provider?: string
11
+ effort?: 'low' | 'medium' | 'high' | 'max'
12
+ }
13
+
14
+ /**
15
+ * Workflow agent() primitive — creates a SubAgent with optional
16
+ * provider/model override and structured output schema.
17
+ */
18
+ export async function workflowAgent(
19
+ prompt: string,
20
+ registry: ProviderRegistry,
21
+ toolRegistry: Map<string, ToolDefinition>,
22
+ opts: WorkflowAgentOpts = {},
23
+ ): Promise<unknown> {
24
+ // If provider override, switch temporarily
25
+ if (opts.provider) {
26
+ registry.switchProvider(opts.provider, opts.model)
27
+ } else if (opts.model) {
28
+ registry.switchProvider(registry.getActive().config.id, opts.model)
29
+ }
30
+
31
+ const sub = new SubAgent(registry, toolRegistry)
32
+ const result = await sub.execute(prompt, opts.label || 'workflow-agent', {
33
+ type: 'general',
34
+ modelOverride: opts.model,
35
+ allowedTools: undefined, // use all tools by default
36
+ })
37
+
38
+ // If schema is provided, attempt to parse the result as JSON
39
+ if (opts.schema) {
40
+ try {
41
+ const parsed = JSON.parse(result)
42
+ // Basic validation: return parsed object
43
+ return parsed
44
+ } catch {
45
+ // Return raw text if JSON parse fails
46
+ return { raw: result }
47
+ }
48
+ }
49
+
50
+ return result
51
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * parallel() — barrier: executes all thunks concurrently, waits for all.
3
+ * Failed thunks resolve to null. Never throws.
4
+ */
5
+ export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<(T | null)[]> {
6
+ const results = await Promise.allSettled(thunks.map((t) => t()))
7
+ return results.map((r) => (r.status === 'fulfilled' ? r.value : null))
8
+ }
@@ -0,0 +1,19 @@
1
+ let currentPhase: string = ''
2
+
3
+ /**
4
+ * Set the current workflow phase title.
5
+ * In a real implementation, this emits to a progress tracker.
6
+ */
7
+ export function phase(title: string): void {
8
+ currentPhase = title
9
+ }
10
+
11
+ /** Get the current workflow phase title. */
12
+ export function getCurrentPhase(): string {
13
+ return currentPhase
14
+ }
15
+
16
+ /** Reset the current phase state (useful for testing). */
17
+ export function resetPhase(): void {
18
+ currentPhase = ''
19
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * pipeline() — no barrier: each item flows through all stages independently.
3
+ * Item A can be in stage 3 while item B is still in stage 1.
4
+ * Failed items become null and skip remaining stages.
5
+ */
6
+ export async function pipeline<T, R>(
7
+ items: T[],
8
+ ...stages: Array<(item: T, index: number, original: T) => Promise<R>>
9
+ ): Promise<(R | null)[]> {
10
+ // Process each item through all stages concurrently
11
+ return Promise.all(
12
+ items.map(async (item, index) => {
13
+ let current: unknown = item
14
+ for (const stage of stages) {
15
+ try {
16
+ current = await stage(current as T, index, item)
17
+ } catch {
18
+ return null // item failed, skip remaining stages
19
+ }
20
+ }
21
+ return current as R
22
+ }),
23
+ )
24
+ }
@@ -0,0 +1,87 @@
1
+ import { createSandbox } from './sandbox'
2
+ import { createJournal, appendJournal } from './journal'
3
+ import { createBudget } from './budget'
4
+ import { workflowAgent } from './primitives/agent'
5
+ import { parallel } from './primitives/parallel'
6
+ import { pipeline } from './primitives/pipeline'
7
+ import { phase as phasePrimitive } from './primitives/phase'
8
+ import type { ProviderRegistry } from '../providers/registry'
9
+ import type { QueryEngine } from '../core/engine'
10
+
11
+ export interface WorkflowRunResult {
12
+ runId: string
13
+ result: unknown
14
+ journalEntries: number
15
+ }
16
+
17
+ /**
18
+ * Execute a workflow script string.
19
+ *
20
+ * The script is evaluated in a sandboxed context with the workflow
21
+ * primitives (agent, parallel, pipeline, phase, args, budget) injected.
22
+ */
23
+ export async function runWorkflow(
24
+ script: string,
25
+ engine: QueryEngine,
26
+ args: unknown = {},
27
+ budgetTotal: number | null = null,
28
+ ): Promise<WorkflowRunResult> {
29
+ const runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
30
+ createJournal(runId, script)
31
+
32
+ const registry: ProviderRegistry = engine.getRegistry()
33
+ const toolRegistry = engine.getTools()
34
+
35
+ const budget = createBudget(budgetTotal)
36
+
37
+ // Wrap primitives with journal recording
38
+ const agent = async (prompt: string, opts?: Record<string, unknown>) => {
39
+ const result = await workflowAgent(
40
+ prompt,
41
+ registry,
42
+ toolRegistry,
43
+ opts as Record<string, unknown>,
44
+ )
45
+ appendJournal(runId, {
46
+ type: 'agent',
47
+ prompt,
48
+ opts: opts as Record<string, unknown> | undefined,
49
+ result,
50
+ })
51
+ return result
52
+ }
53
+
54
+ const wrappedPhase = (title: string) => {
55
+ phasePrimitive(title)
56
+ appendJournal(runId, { type: 'phase', message: title })
57
+ }
58
+
59
+ const log = (message: string) => {
60
+ appendJournal(runId, { type: 'log', message })
61
+ }
62
+
63
+ const sandbox = createSandbox(args, budget)
64
+
65
+ // Build the script wrapper
66
+ const wrappedScript = `
67
+ return (async () => {
68
+ ${script}
69
+ })()
70
+ `
71
+
72
+ // Execute in sandboxed context
73
+ const scriptFn = new Function(
74
+ 'agent',
75
+ 'parallel',
76
+ 'pipeline',
77
+ 'phase',
78
+ 'log',
79
+ 'args',
80
+ 'budget',
81
+ wrappedScript,
82
+ )
83
+
84
+ const result = await scriptFn(agent, parallel, pipeline, wrappedPhase, log, args, budget)
85
+
86
+ return { runId, result, journalEntries: 0 }
87
+ }
@@ -0,0 +1,75 @@
1
+ /** APIs disabled in workflow scripts to ensure deterministic replay. */
2
+ const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
3
+
4
+ /**
5
+ * Create a sandboxed global scope for workflow script execution.
6
+ * Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID().
7
+ */
8
+ export function createSandbox(
9
+ args: unknown,
10
+ budget: { total: number | null; spent(): number; remaining(): number },
11
+ ): Record<string, unknown> {
12
+ const sandbox: Record<string, unknown> = {
13
+ args,
14
+ budget,
15
+ console: {
16
+ log: (..._a: unknown[]) => {}, // no-op in sandbox
17
+ error: (..._a: unknown[]) => {},
18
+ },
19
+ // Primitives are injected by the runtime, not the sandbox
20
+ }
21
+
22
+ // Override Date to block now() and argless constructor
23
+ const OriginalDate = Date
24
+ sandbox.Date = new Proxy(OriginalDate, {
25
+ construct(_target, constructorArgs) {
26
+ if (constructorArgs.length === 0) {
27
+ throw new Error('new Date() is disabled in workflow sandbox. Pass timestamps via args.')
28
+ }
29
+ return new (OriginalDate as unknown as new (...a: unknown[]) => Date)(
30
+ ...(constructorArgs as [number]),
31
+ )
32
+ },
33
+ get(_target, prop) {
34
+ if (prop === 'now') {
35
+ throw new Error('Date.now() is disabled in workflow sandbox. Pass timestamps via args.')
36
+ }
37
+ const val = (OriginalDate as unknown as Record<string, unknown>)[prop as string]
38
+ return typeof val === 'function' ? (val as Function).bind(OriginalDate) : val
39
+ },
40
+ })
41
+
42
+ // Override Math.random
43
+ sandbox.Math = new Proxy(Math, {
44
+ get(_target, prop) {
45
+ if (prop === 'random') {
46
+ throw new Error('Math.random() is disabled in workflow sandbox. Use a seed from args.')
47
+ }
48
+ const val = (Math as unknown as Record<string, unknown>)[prop as string]
49
+ return typeof val === 'function' ? (val as Function).bind(Math) : val
50
+ },
51
+ })
52
+
53
+ // Block crypto.randomUUID
54
+ const globalCrypto = (globalThis as Record<string, unknown>).crypto as
55
+ | { randomUUID?: unknown; [key: string]: unknown }
56
+ | undefined
57
+ if (globalCrypto) {
58
+ sandbox.crypto = new Proxy(globalCrypto, {
59
+ get(_target, prop) {
60
+ if (prop === 'randomUUID') {
61
+ throw new Error('crypto.randomUUID() is disabled in workflow sandbox.')
62
+ }
63
+ const val = (globalCrypto as Record<string, unknown>)[prop as string]
64
+ return typeof val === 'function' ? (val as Function).bind(globalCrypto) : val
65
+ },
66
+ })
67
+ }
68
+
69
+ return sandbox
70
+ }
71
+
72
+ /** Check whether a given API identifier is in the forbidden set. */
73
+ export function isForbidden(id: string): boolean {
74
+ return FORBIDDEN.has(id)
75
+ }