@geekbeer/minion 2.33.4 → 2.42.5

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/.env.example CHANGED
@@ -17,6 +17,3 @@ MINION_ID=
17
17
 
18
18
  # Agent port (optional, default: 8080)
19
19
  AGENT_PORT=8080
20
-
21
- # Heartbeat interval in seconds (optional, default: 30)
22
- HEARTBEAT_INTERVAL=30
package/README.md CHANGED
@@ -58,7 +58,6 @@ await reportIssue({
58
58
  | `API_TOKEN` | Authentication token | - |
59
59
  | `MINION_ID` | Minion UUID | - |
60
60
  | `AGENT_PORT` | Agent API listen port | `8080` |
61
- | `HEARTBEAT_INTERVAL` | Heartbeat interval (ms) | `30000` |
62
61
 
63
62
  ## License
64
63
 
package/core/api.js CHANGED
@@ -74,9 +74,22 @@ async function reportIssue(data) {
74
74
  })
75
75
  }
76
76
 
77
+ /**
78
+ * Send heartbeat to HQ to report current status.
79
+ * Called periodically, on startup, on shutdown, and on status change.
80
+ * @param {object} data - { status: 'online' | 'offline' | 'busy', current_task?: string | null, version?: string }
81
+ */
82
+ async function sendHeartbeat(data) {
83
+ return request('/heartbeat', {
84
+ method: 'POST',
85
+ body: JSON.stringify(data),
86
+ })
87
+ }
88
+
77
89
  module.exports = {
78
90
  request,
79
91
  reportExecution,
80
92
  reportStepComplete,
81
93
  reportIssue,
94
+ sendHeartbeat,
82
95
  }
package/core/config.js CHANGED
@@ -15,8 +15,40 @@
15
15
  */
16
16
 
17
17
  const os = require('os')
18
+ const fs = require('fs')
18
19
  const { execSync } = require('child_process')
19
20
 
21
+ /**
22
+ * Load .env file into process.env (without overwriting existing values).
23
+ * This ensures values written to the .env file at runtime (e.g. LLM_COMMAND
24
+ * set via the config API) are picked up on process restart, even when the
25
+ * process manager (supervisord) does not include them in its environment line.
26
+ */
27
+ function loadEnvFile() {
28
+ const { resolveEnvFilePath } = require('./lib/platform')
29
+ // resolveHomeDir is not available yet, use a lightweight fallback
30
+ const home = process.env.HOME || os.homedir()
31
+ const envPath = resolveEnvFilePath(home)
32
+ try {
33
+ const content = fs.readFileSync(envPath, 'utf-8')
34
+ for (const line of content.split('\n')) {
35
+ const trimmed = line.trim()
36
+ if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue
37
+ const eqIdx = trimmed.indexOf('=')
38
+ const key = trimmed.slice(0, eqIdx).trim()
39
+ const value = trimmed.slice(eqIdx + 1).trim()
40
+ // Do not overwrite values already set by the process manager
41
+ if (!(key in process.env)) {
42
+ process.env[key] = value
43
+ }
44
+ }
45
+ } catch {
46
+ // .env file doesn't exist or can't be read — not an error
47
+ }
48
+ }
49
+
50
+ loadEnvFile()
51
+
20
52
  /**
21
53
  * Resolve the correct home directory for the minion user.
22
54
  * On Linux, supervisord environments may set HOME=/root incorrectly.
@@ -82,4 +114,17 @@ function isLlmConfigured() {
82
114
  return !!config.LLM_COMMAND
83
115
  }
84
116
 
85
- module.exports = { config, validate, isHqConfigured, isLlmConfigured }
117
+ /**
118
+ * Update a config value at runtime (e.g. after .env file write).
119
+ * Also syncs to process.env so child processes inherit the change.
120
+ * @param {string} key
121
+ * @param {string} value
122
+ */
123
+ function updateConfig(key, value) {
124
+ if (key in config) {
125
+ config[key] = value
126
+ process.env[key] = value
127
+ }
128
+ }
129
+
130
+ module.exports = { config, validate, isHqConfigured, isLlmConfigured, updateConfig }
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs').promises
8
8
  const path = require('path')
9
9
  const platform = require('./platform')
10
+ const { stripAnsi } = require('./strip-ansi')
10
11
 
11
12
  // Log storage configuration (platform-aware via platform.js)
12
13
  const LOG_DIR = platform.LOG_DIR
@@ -59,7 +60,9 @@ async function readLog(executionId, options = {}) {
59
60
 
60
61
  try {
61
62
  const stats = await fs.stat(logPath)
62
- const content = await fs.readFile(logPath, 'utf-8')
63
+ const rawContent = await fs.readFile(logPath, 'utf-8')
64
+ // Strip ANSI/TTY escape sequences from tmux pipe-pane output
65
+ const content = stripAnsi(rawContent)
63
66
  const allLines = content.split('\n')
64
67
 
65
68
  let resultContent = content
@@ -16,22 +16,15 @@ const TEMP_DIR = os.tmpdir()
16
16
 
17
17
  /**
18
18
  * Resolve the data directory for minion agent persistent files.
19
- * Windows: %PROGRAMDATA%\minion-agent (or fallback to %USERPROFILE%\.minion-agent)
19
+ * Windows: %USERPROFILE%\.minion (matches minion-cli.ps1 / start-agent.ps1)
20
20
  * Linux: /opt/minion-agent (existing behavior)
21
21
  */
22
22
  function resolveDataDir() {
23
23
  if (IS_WINDOWS) {
24
- const programData = process.env.PROGRAMDATA || process.env.ALLUSERSPROFILE
25
- if (programData) {
26
- const dir = path.join(programData, 'minion-agent')
27
- try {
28
- fs.mkdirSync(dir, { recursive: true })
29
- return dir
30
- } catch {
31
- // Fall through to home-based path
32
- }
33
- }
34
- return path.join(os.homedir(), '.minion-agent')
24
+ // Use ~/.minion to match minion-cli.ps1 and start-agent.ps1.
25
+ // All Windows-specific code (CLI, process-manager, server.js) uses ~/.minion
26
+ // as the canonical data directory, so core modules must align with it.
27
+ return path.join(os.homedir(), '.minion')
35
28
  }
36
29
  return '/opt/minion-agent'
37
30
  }
@@ -89,7 +82,9 @@ function getDefaultShell() {
89
82
 
90
83
  /**
91
84
  * Resolve .env file path.
92
- * Prefers DATA_DIR/.env, falls back to ~/minion.env.
85
+ * Returns DATA_DIR/.env (which is ~/.minion/.env on Windows,
86
+ * /opt/minion-agent/.env on Linux).
87
+ * Falls back to ~/minion.env if DATA_DIR is not writable.
93
88
  * @param {string} homeDir - User home directory
94
89
  * @returns {string} Path to .env file
95
90
  */
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Revision Watcher
3
+ *
4
+ * PM-only polling daemon that detects steps with revision_requested
5
+ * review status and handles revision routing.
6
+ *
7
+ * When a reviewer requests changes on a completed step:
8
+ * 1. This watcher detects the revision_requested status
9
+ * 2. Uses LLM to decide which step to roll back to
10
+ * 3. Calls HQ's /api/minion/revision-reset to reset affected steps
11
+ * 4. The step-poller on the target minion picks up the re-pending step
12
+ *
13
+ * Only runs on minions that have PM role in at least one project.
14
+ */
15
+
16
+ const { config, isHqConfigured } = require('../config')
17
+ const api = require('../api')
18
+
19
+ // Poll every 30 seconds (same frequency as step-poller)
20
+ const POLL_INTERVAL_MS = 30_000
21
+
22
+ let polling = false
23
+ let pollTimer = null
24
+
25
+ // Track revisions being processed to avoid duplicate handling
26
+ const processingRevisions = new Set()
27
+
28
+ /**
29
+ * Poll HQ for pending revisions and handle them.
30
+ */
31
+ async function pollOnce() {
32
+ if (!isHqConfigured()) return
33
+ if (polling) return
34
+
35
+ polling = true
36
+ try {
37
+ const data = await api.request('/pending-revisions')
38
+
39
+ if (!data.revisions || data.revisions.length === 0) {
40
+ return
41
+ }
42
+
43
+ console.log(`[RevisionWatcher] Found ${data.revisions.length} pending revision(s)`)
44
+
45
+ for (const revision of data.revisions) {
46
+ const key = `${revision.execution_id}-${revision.revision_step_index}`
47
+ if (processingRevisions.has(key)) {
48
+ continue
49
+ }
50
+
51
+ processingRevisions.add(key)
52
+ try {
53
+ await handleRevision(revision)
54
+ } finally {
55
+ processingRevisions.delete(key)
56
+ }
57
+ }
58
+ } catch (err) {
59
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
60
+ console.log(`[RevisionWatcher] HQ unreachable, will retry next cycle`)
61
+ } else {
62
+ console.error(`[RevisionWatcher] Poll error: ${err.message}`)
63
+ }
64
+ } finally {
65
+ polling = false
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Handle a single revision request:
71
+ * 1. Decide which step to roll back to (LLM or simple heuristic)
72
+ * 2. Call revision-reset API
73
+ *
74
+ * @param {object} revision
75
+ * @param {string} revision.execution_id
76
+ * @param {string} revision.workflow_name
77
+ * @param {number} revision.revision_step_index
78
+ * @param {string} revision.review_comment
79
+ * @param {Array<{step_index: number, skill_name: string|null, assigned_role: string}>} revision.pipeline
80
+ */
81
+ async function handleRevision(revision) {
82
+ const { execution_id, workflow_name, revision_step_index, review_comment, pipeline } = revision
83
+
84
+ console.log(
85
+ `[RevisionWatcher] Handling revision for "${workflow_name}" step ${revision_step_index}: "${review_comment}"`
86
+ )
87
+
88
+ // Decide target step for rollback
89
+ const targetStepIndex = await decideRevisionTarget(pipeline, review_comment, revision_step_index)
90
+
91
+ console.log(`[RevisionWatcher] Rolling back to step ${targetStepIndex}`)
92
+
93
+ // Call revision-reset API on HQ
94
+ try {
95
+ await api.request('/revision-reset', {
96
+ method: 'POST',
97
+ body: JSON.stringify({
98
+ execution_id,
99
+ target_step_index: targetStepIndex,
100
+ revision_step_index: revision_step_index,
101
+ revision_feedback: review_comment,
102
+ }),
103
+ })
104
+
105
+ console.log(
106
+ `[RevisionWatcher] Revision reset complete: steps ${targetStepIndex}-${revision_step_index} ` +
107
+ `of execution ${execution_id} reset to pending`
108
+ )
109
+ } catch (err) {
110
+ console.error(`[RevisionWatcher] Revision reset failed: ${err.message}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Decide which step to roll back to.
116
+ * Uses LLM when multiple steps are involved, otherwise defaults to the reviewed step.
117
+ */
118
+ async function decideRevisionTarget(pipeline, reviewComment, currentStepIndex) {
119
+ // If only one step or reviewing the first step, no choice needed
120
+ if (currentStepIndex === 0 || pipeline.length <= 1) {
121
+ return currentStepIndex
122
+ }
123
+
124
+ // Build pipeline description for LLM
125
+ const pipelineDesc = pipeline
126
+ .map(s => `Step ${s.step_index}: ${s.skill_name || 'unknown'} (role: ${s.assigned_role})`)
127
+ .join('\n')
128
+
129
+ const systemPrompt = `You are analyzing a workflow pipeline to decide which step to roll back to after a reviewer requested changes.
130
+
131
+ Given the pipeline steps and the reviewer's feedback, determine which step is the root cause that needs to be re-executed.
132
+ - If the feedback targets the current step's output only, return the current step index.
133
+ - If the feedback suggests an earlier step produced incorrect input, return that earlier step's index.
134
+ - Always return the EARLIEST step that needs re-execution.
135
+
136
+ Respond with ONLY a JSON object: {"target_step_index": <number>}
137
+ Do not include any other text.`
138
+
139
+ const userPrompt = `## Pipeline (steps 0 through ${currentStepIndex})
140
+ ${pipelineDesc}
141
+
142
+ ## Reviewer Feedback
143
+ ${reviewComment}
144
+
145
+ ## Current Step (reviewed)
146
+ Step ${currentStepIndex}`
147
+
148
+ // Load optional PM revision policy
149
+ let revisionPolicy = ''
150
+ try {
151
+ const fs = require('fs').promises
152
+ const path = require('path')
153
+ const policyPath = path.join(config.HOME_DIR, '.minion', 'revision-policy.md')
154
+ revisionPolicy = await fs.readFile(policyPath, 'utf-8')
155
+ } catch {
156
+ // No custom policy
157
+ }
158
+
159
+ if (revisionPolicy) {
160
+ // Append policy to user prompt (same as orchestrator did)
161
+ }
162
+
163
+ try {
164
+ const result = await callLlmForJson(systemPrompt, userPrompt)
165
+
166
+ if (
167
+ result &&
168
+ typeof result.target_step_index === 'number' &&
169
+ Number.isInteger(result.target_step_index) &&
170
+ result.target_step_index >= 0 &&
171
+ result.target_step_index <= currentStepIndex
172
+ ) {
173
+ console.log(`[RevisionWatcher] LLM decided revision target: step ${result.target_step_index}`)
174
+ return result.target_step_index
175
+ }
176
+
177
+ console.warn(`[RevisionWatcher] LLM returned invalid target, falling back to step ${currentStepIndex}`)
178
+ return currentStepIndex
179
+ } catch (err) {
180
+ console.error(`[RevisionWatcher] LLM call failed, falling back to step ${currentStepIndex}: ${err.message}`)
181
+ return currentStepIndex
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Call LLM API for JSON response.
187
+ * Reuses the same Anthropic Messages API pattern as workflow-orchestrator.
188
+ */
189
+ async function callLlmForJson(systemPrompt, userPrompt) {
190
+ const apiKey = process.env.ANTHROPIC_API_KEY
191
+ if (!apiKey) {
192
+ throw new Error('ANTHROPIC_API_KEY not set — cannot make LLM call for revision routing')
193
+ }
194
+
195
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'x-api-key': apiKey,
200
+ 'anthropic-version': '2023-06-01',
201
+ },
202
+ body: JSON.stringify({
203
+ model: 'claude-haiku-4-5-20251001',
204
+ max_tokens: 256,
205
+ system: systemPrompt,
206
+ messages: [{ role: 'user', content: userPrompt }],
207
+ }),
208
+ })
209
+
210
+ if (!resp.ok) {
211
+ const text = await resp.text()
212
+ throw new Error(`Anthropic API error: ${resp.status} ${text}`)
213
+ }
214
+
215
+ const data = await resp.json()
216
+ const content = data.content?.[0]?.text
217
+ if (!content) {
218
+ throw new Error('Empty response from Anthropic API')
219
+ }
220
+
221
+ return JSON.parse(content)
222
+ }
223
+
224
+ /**
225
+ * Start the revision watcher daemon.
226
+ */
227
+ function start() {
228
+ if (!isHqConfigured()) {
229
+ console.log('[RevisionWatcher] HQ not configured, revision watcher disabled')
230
+ return
231
+ }
232
+
233
+ // Initial poll after a short delay
234
+ setTimeout(() => pollOnce(), 8000)
235
+
236
+ // Periodic polling
237
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
238
+ console.log(`[RevisionWatcher] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
239
+ }
240
+
241
+ /**
242
+ * Stop the revision watcher daemon.
243
+ */
244
+ function stop() {
245
+ if (pollTimer) {
246
+ clearInterval(pollTimer)
247
+ pollTimer = null
248
+ console.log('[RevisionWatcher] Stopped')
249
+ }
250
+ }
251
+
252
+ module.exports = { start, stop, pollOnce }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Step Poller
3
+ *
4
+ * Polling daemon that runs on every minion (including PM).
5
+ * Periodically checks HQ for pending workflow steps assigned to this
6
+ * minion's role, then fetches the skill and executes it.
7
+ *
8
+ * This enables the Pull model: minions autonomously pick up work
9
+ * when their turn comes, without needing a PM to push-dispatch.
10
+ * Handles minion absence gracefully — when a minion comes online,
11
+ * it simply picks up any pending steps waiting for its role.
12
+ *
13
+ * Flow per poll cycle:
14
+ * 1. GET /api/minion/pending-steps → list of actionable steps
15
+ * 2. For each step (one at a time):
16
+ * a. POST /api/skills/fetch/:name → deploy skill locally
17
+ * b. POST /api/skills/run → execute in tmux session
18
+ * c. (step-complete is reported by the /api/skills/run post-execution hook)
19
+ */
20
+
21
+ const { config, isHqConfigured } = require('../config')
22
+ const api = require('../api')
23
+
24
+ // Polling interval: 30 seconds (matches heartbeat frequency)
25
+ const POLL_INTERVAL_MS = 30_000
26
+
27
+ // Prevent concurrent poll cycles from overlapping
28
+ let polling = false
29
+
30
+ // Timer reference for cleanup
31
+ let pollTimer = null
32
+
33
+ // Track currently executing step to avoid double-dispatch
34
+ let activeStepExecutionId = null
35
+
36
+ /**
37
+ * Poll HQ for pending steps and execute them.
38
+ */
39
+ async function pollOnce() {
40
+ if (!isHqConfigured()) return
41
+ if (polling) return
42
+
43
+ polling = true
44
+ try {
45
+ // 1. Fetch pending steps from HQ
46
+ const data = await api.request('/pending-steps')
47
+
48
+ if (!data.steps || data.steps.length === 0) {
49
+ return
50
+ }
51
+
52
+ console.log(`[StepPoller] Found ${data.steps.length} pending step(s)`)
53
+
54
+ // 2. Process steps one at a time (sequential execution)
55
+ for (const step of data.steps) {
56
+ // Skip if we're already executing this step
57
+ if (activeStepExecutionId === step.step_execution_id) {
58
+ console.log(`[StepPoller] Step ${step.step_execution_id} already in progress, skipping`)
59
+ continue
60
+ }
61
+
62
+ await executeStep(step)
63
+
64
+ // Only execute one step per poll cycle to avoid overloading
65
+ break
66
+ }
67
+ } catch (err) {
68
+ // Don't log network errors at error level — they're expected when HQ is temporarily unreachable
69
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
70
+ console.log(`[StepPoller] HQ unreachable, will retry next cycle`)
71
+ } else {
72
+ console.error(`[StepPoller] Poll error: ${err.message}`)
73
+ }
74
+ } finally {
75
+ polling = false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Execute a single pending step:
81
+ * 1. Claim the step by calling dispatch-self endpoint
82
+ * 2. Fetch the skill from HQ
83
+ * 3. Run the skill locally
84
+ *
85
+ * @param {object} step - Step info from pending-steps response
86
+ */
87
+ async function executeStep(step) {
88
+ const {
89
+ step_execution_id,
90
+ execution_id,
91
+ workflow_name,
92
+ step_index,
93
+ skill_version_id,
94
+ assigned_role,
95
+ skill_name,
96
+ revision_feedback,
97
+ } = step
98
+
99
+ console.log(
100
+ `[StepPoller] Executing step ${step_index} of "${workflow_name}" ` +
101
+ `(skill: ${skill_name || skill_version_id}, role: ${assigned_role})`
102
+ )
103
+
104
+ activeStepExecutionId = step_execution_id
105
+
106
+ try {
107
+ // 1. Claim the step — tell HQ we're taking it
108
+ // This sets status to 'running' and prevents other minions from picking it up
109
+ try {
110
+ await api.request('/claim-step', {
111
+ method: 'POST',
112
+ body: JSON.stringify({
113
+ execution_id,
114
+ step_index,
115
+ }),
116
+ })
117
+ } catch (claimErr) {
118
+ // 409 means step is no longer pending (already claimed or completed)
119
+ if (claimErr.statusCode === 409) {
120
+ console.log(`[StepPoller] Step ${step_index} already claimed, skipping`)
121
+ return
122
+ }
123
+ throw claimErr
124
+ }
125
+
126
+ // 2. Fetch the skill from HQ to ensure it's deployed locally
127
+ if (skill_name) {
128
+ try {
129
+ const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}`
130
+ const fetchResp = await fetch(fetchUrl, {
131
+ method: 'POST',
132
+ headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
133
+ })
134
+ if (!fetchResp.ok) {
135
+ console.error(`[StepPoller] Skill fetch failed: ${await fetchResp.text()}`)
136
+ }
137
+ } catch (fetchErr) {
138
+ console.error(`[StepPoller] Skill fetch error: ${fetchErr.message}`)
139
+ // Continue — skill may already be deployed locally
140
+ }
141
+ }
142
+
143
+ // 3. Run the skill via local API
144
+ const runPayload = {
145
+ skill_name,
146
+ execution_id,
147
+ step_index,
148
+ workflow_name,
149
+ role: assigned_role,
150
+ }
151
+ if (revision_feedback) {
152
+ runPayload.revision_feedback = revision_feedback
153
+ }
154
+
155
+ const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
156
+ const runResp = await fetch(runUrl, {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
161
+ },
162
+ body: JSON.stringify(runPayload),
163
+ })
164
+
165
+ if (!runResp.ok) {
166
+ const errData = await runResp.json().catch(() => ({}))
167
+ console.error(`[StepPoller] Skill run failed: ${errData.error || runResp.status}`)
168
+ // Report failure to HQ
169
+ try {
170
+ await api.reportStepComplete({
171
+ workflow_execution_id: execution_id,
172
+ step_index,
173
+ status: 'failed',
174
+ output_summary: `Step poller failed to start skill: ${errData.error || 'unknown error'}`,
175
+ })
176
+ } catch (reportErr) {
177
+ console.error(`[StepPoller] Failed to report step failure: ${reportErr.message}`)
178
+ }
179
+ return
180
+ }
181
+
182
+ const runData = await runResp.json()
183
+ console.log(
184
+ `[StepPoller] Skill "${skill_name}" started (session: ${runData.session_name}). ` +
185
+ `Step completion will be reported by the post-execution hook.`
186
+ )
187
+ } catch (err) {
188
+ console.error(`[StepPoller] Failed to execute step ${step_index}: ${err.message}`)
189
+ } finally {
190
+ activeStepExecutionId = null
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Start the polling daemon.
196
+ */
197
+ function start() {
198
+ if (!isHqConfigured()) {
199
+ console.log('[StepPoller] HQ not configured, step poller disabled')
200
+ return
201
+ }
202
+
203
+ // Initial poll after a short delay (let server fully start)
204
+ setTimeout(() => pollOnce(), 5000)
205
+
206
+ // Periodic polling
207
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
208
+ console.log(`[StepPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
209
+ }
210
+
211
+ /**
212
+ * Stop the polling daemon.
213
+ */
214
+ function stop() {
215
+ if (pollTimer) {
216
+ clearInterval(pollTimer)
217
+ pollTimer = null
218
+ console.log('[StepPoller] Stopped')
219
+ }
220
+ }
221
+
222
+ module.exports = { start, stop, pollOnce }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Strip ANSI escape sequences from a string.
3
+ * Handles CSI sequences, OSC sequences, and other control codes
4
+ * that terminal emulators (node-pty) produce.
5
+ */
6
+
7
+ // Match ANSI escape sequences:
8
+ // - CSI: ESC[ ... (params including <>=:;?) ... (final byte)
9
+ // - OSC: ESC] ... ST (string terminator: ESC\ or BEL, or next ESC)
10
+ // - Other ESC sequences: ESC followed by a character
11
+ // - Standalone control characters (except newline, carriage return, tab)
12
+ const ANSI_REGEX = /(?:\x1B\[[0-9;?<>=:]*[A-Za-z~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\|\x1B(?=\[|\]))|\x1B[^[\]()][A-Za-z]?|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F])/g
13
+
14
+ function stripAnsi(str) {
15
+ return str.replace(ANSI_REGEX, '')
16
+ }
17
+
18
+ module.exports = { stripAnsi }