openvisio-agent 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
@@ -56,6 +56,8 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
56
56
 
57
57
  Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
58
58
 
59
+ Routine messages, triage, introductions, catch-up checks, and ticket movement use a lightweight model by default. Claude uses Haiku and Codex uses `gpt-5.6-luna`. Requests that clearly require repository work route to the coding model: Sonnet for Claude and `gpt-5.6-sol` for Codex. Override either lane with `--chat-model` and `--model`.
60
+
59
61
  ```bash
60
62
  openvisio-agent watch --name ada # run in this terminal
61
63
  openvisio-agent watch --name ada --install # run in the background, start at login
package/bin/cli.mjs CHANGED
@@ -54,12 +54,12 @@ connect --backend
54
54
  Codex authenticates separately with \`codex login\`; opencode
55
55
  authenticates with \`opencode auth login\`.
56
56
  --chat-only disable code work — chat/ticket tools only.
57
- --model <m> model passed to the selected runtime. Claude defaults to
58
- sonnet; Codex and OpenCode use their configured default.
57
+ --model <m> coding model. Claude defaults to sonnet; Codex defaults to
58
+ gpt-5.6-sol; OpenCode uses its configured default.
59
59
  Engineers can also change it live from chat: "@agent /model …".
60
- --chat-model <m> run the lighter chat/mention cycles on an even cheaper model
61
- while code work stays on --model (e.g. --model sonnet
62
- --chat-model haiku).
60
+ --chat-model <m> lightweight coordination model for chat, triage, and board
61
+ movement. Defaults: Claude haiku, Codex gpt-5.6-luna.
62
+ Coding work stays on --model.
63
63
  --no-service skip the background service — just save config + print the
64
64
  watch commands to run yourself.
65
65
 
@@ -91,7 +91,8 @@ function mcpReplace(claude, addArgs) {
91
91
  // background service) to work — an `npx` connect leaves nothing installed. Make
92
92
  // the global install part of setup.
93
93
  function ensureAgentInstalled() {
94
- if (onPath('openvisio-agent')) return
94
+ const current = onPath('openvisio-agent')
95
+ if (current && !current.includes('/_npx/')) return
95
96
  info('Installing openvisio-agent globally (so `watch` and the always-on service have a stable command)…')
96
97
  spawnSync('npm', ['i', '-g', 'openvisio-agent@latest'], { stdio: 'inherit', shell: process.platform === 'win32' })
97
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/watch.mjs CHANGED
@@ -29,7 +29,9 @@ const REPLY_DISCIPLINE = [
29
29
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
30
30
  ' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
31
31
  ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
32
- ' • NO INVENTED HISTORY. You have NO memory beyond the messages visible in THIS thread and what your tools return right now. Never fabricate past events, competitions, conversations, results, links, PR numbers, deploy URLs, or figures. If you are asked about something you have no actual record of, say plainly "I don\'t have a record of that" do NOT make one up to play along or be helpful. Only state things you can see or verify.',
32
+ ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
33
+ ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
34
+ ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
33
35
  ].join('\n')
34
36
 
35
37
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
@@ -51,6 +53,13 @@ const CYCLE_FAST = [
51
53
  'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket. 1-3 sentences, no summary. Then stop.',
52
54
  ].join('\n')
53
55
 
56
+ const COORDINATE = [
57
+ 'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
58
+ 'Call get_marching_orders or poll_inbox only when the event context does not already contain enough detail. Use update_ticket to move a ticket to the correct board column when requested or when non-code work is complete.',
59
+ 'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified, acknowledge it once and state that it needs the coding lane. Do not pretend it is complete.',
60
+ 'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing acknowledgement.',
61
+ ].join('\n')
62
+
54
63
  // ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
55
64
  // A stable "who you are / how you work" charter prepended to every code cycle.
56
65
  const CODE_CHARTER = [
@@ -58,7 +67,7 @@ const CODE_CHARTER = [
58
67
  ' • openvisio-team chat/ticket tools — read mentions, post messages, comment on tickets, react (post_message / poll_inbox / comment_ticket / react_message; your runtime may namespace them — use whatever names appear in your tool list).',
59
68
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
60
69
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
61
- 'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. You are NOT limited to one repo you work across ALL of them from here. For any task: find the relevant repo under the workspace; if it isn\'t cloned yet, CLONE it (gh repo clone <org>/<repo>, or git clone <url>) and then work inside that subfolder. Never make the user tell you a path or clone things for you.',
70
+ 'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
62
71
  'CAPABILITY CHECK: before you EVER answer "I can\'t do that", verify against the tools above. If a tool exists for it, DO it. To be explicit: you CAN read/inspect any of the org\'s codebases, clone a repo you don\'t have yet, work on it, create a branch, and raise a PR — say YES to these and then actually do them.',
63
72
  '',
64
73
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
@@ -74,7 +83,7 @@ const CODE_FULL = [
74
83
  'THIS CYCLE: call get_marching_orders and poll_inbox to see assigned tickets + mentions, then act on them.',
75
84
  'ACKNOWLEDGE ONCE: for a task assigned to you that you have NOT already acknowledged, post a SINGLE one-line comment_ticket ("On it — picking this up now") before you start. First check the ticket/thread — if you already acknowledged it on an earlier cycle, skip this and just keep working. Then report only when you have the result.',
76
85
  'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
77
- ' 1. GET THE CODE: locate the target repo under your workspace root. If it\'s already a subfolder, cd in and `git pull`; if it isn\'t cloned yet, clone it into the workspace (gh repo clone <org>/<repo>, or git clone <url>) and cd in. Do this yourself never ask the user for a path.',
86
+ ' 1. GET THE CODE: use verified thread/history/recall context first, locate the target repo under your workspace root, and read its AGENTS.md. Reuse an existing clone; clone only if absent. Check `git status` before changing anything and preserve unrelated user work. Update from the remote only when it is safe. Do this yourself; never ask the user for a path you can discover.',
78
87
  ' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
79
88
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
80
89
  ' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
@@ -206,10 +215,12 @@ export async function runWatch({ flags }) {
206
215
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
207
216
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
208
217
 
209
- // Claude defaults to Sonnet; Codex and OpenCode use their configured defaults
210
- // unless explicitly overridden. Optional --chat-model can select a lighter model.
211
- const model = String(flags.model || (saved && saved.model) || (agent === 'claude' ? 'sonnet' : ''))
212
- const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
218
+ // Keep routine coordination on the cheapest capable tier. Reserve the stronger
219
+ // default for coding cycles. Explicit flags and saved choices always win.
220
+ const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol' : ''
221
+ const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-luna' : ''
222
+ const model = String(flags.model || (saved && saved.model) || defaultModel)
223
+ const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
213
224
 
214
225
  // ONE watcher per agent. A second one (e.g. a manual `watch` alongside the
215
226
  // background service, or a stale service) is the #1 cause of duplicate replies:
@@ -217,9 +228,21 @@ export async function runWatch({ flags }) {
217
228
  if (!flags.install) {
218
229
  const lock = acquireSingleInstance(slug || 'openvisio')
219
230
  if (lock.conflict) {
220
- fail(`Another openvisio-agent watcher for "${slug || 'openvisio'}" is already running (pid ${lock.conflict}).\n` +
231
+ const watcherName = slug || 'openvisio'
232
+ const stop = process.platform === 'darwin'
233
+ ? `launchctl unload ~/Library/LaunchAgents/io.openvisio.${watcherName}.plist`
234
+ : process.platform === 'win32'
235
+ ? 'Stop the existing openvisio-agent process in Task Manager.'
236
+ : `systemctl --user stop openvisio-${watcherName}.service`
237
+ const logs = process.platform === 'darwin'
238
+ ? `tail -f ~/.openvisio/${watcherName}.log`
239
+ : `journalctl --user -u openvisio-${watcherName} -f`
240
+ fail(`Another openvisio-agent watcher for "${watcherName}" is already running (pid ${lock.conflict}).\n` +
221
241
  ` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
222
- ` Stop the other one (kill ${lock.conflict}), or rely on ONLY the background service. Refusing to start a second.`)
242
+ ` This is usually the auto-restarting background service; killing its PID only makes it restart.\n` +
243
+ ` To run in this terminal instead, stop the service first:\n ${stop}\n` +
244
+ ` Or keep the service and inspect its log:\n ${logs}\n` +
245
+ ` Refusing to start a second watcher.`)
223
246
  }
224
247
  process.on('exit', () => { try { lock.release && lock.release() } catch { /* noop */ } })
225
248
  }
@@ -321,7 +344,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
321
344
  ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
322
345
  : ''
323
346
  const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
324
- '--sandbox', canCode ? 'workspace-write' : 'read-only', '--approve-for-me',
347
+ ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
325
348
  ...(m ? ['--model', m] : []),
326
349
  ...(mcpOverride ? ['-c', mcpOverride] : []),
327
350
  full]
@@ -501,7 +524,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
501
524
  let liteModel = chatModel || model
502
525
 
503
526
  let busy = false
504
- let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
527
+ let queued = null // 'full' | 'coord' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
505
528
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
506
529
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
507
530
  // re-acknowledge the same assignment.
@@ -517,8 +540,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
517
540
  const pending = []
518
541
 
519
542
  // Higher rank wins when coalescing cycles requested while one is running.
520
- const RANK = { fast: 0, intro: 1, sweep: 2, full: 3 }
521
- const baseFor = (kind) => kind === 'intro' ? INTRO : (kind === 'full' || kind === 'sweep') ? fullPrompt : fastPrompt
543
+ const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
544
+ const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
522
545
 
523
546
  async function drain(kind, context) {
524
547
  if (context) pending.push(context)
@@ -530,7 +553,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
530
553
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
531
554
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
532
555
  // work (full/sweep) uses the main model.
533
- const useModel = (kind === 'fast' || kind === 'intro') ? liteModel : codeModel
556
+ const useModel = kind === 'full' ? codeModel : liteModel
534
557
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
535
558
  // Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
536
559
  // through a long cycle; onTool flips it to "typing" when post_message fires.
@@ -555,6 +578,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
555
578
  return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
556
579
  }
557
580
 
581
+ // Route obvious repository work to the coding lane. Everything else, including
582
+ // chat and board movement, stays on the lightweight coordination model.
583
+ const needsCode = (value) => /\b(?:code|coding|implement|implementation|fix|bug|debug|refactor|test|tests|build|compile|repository|repo|github|git|branch|commit|pull request|pr|endpoint|api|component|function|class|database|migration|schema|deploy|release|package|npm|typescript|javascript|python|swift|rust|golang|css|html|file|files)\b/i.test(String(value || ''))
584
+
558
585
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
559
586
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
560
587
  const normalizeModel = (s) => {
@@ -605,7 +632,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
605
632
  if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
606
633
  log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
607
634
  const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
608
- void drain('full', `A task was just ${k === 'task:created' ? 'created and assigned' : 'assigned'} to an agent in this workspace — task #${t.id != null ? t.id : '?'}: "${t.title || ''}"${desc} (agent_id ${agentId}). Call get_marching_orders to confirm it is assigned to YOU. If it IS yours: FIRST post a brief comment_ticket acknowledgement ("On it — picking this up now, will update shortly"), THEN do the work end-to-end and report back (branch/PR + a channel note). If it is NOT yours, do nothing and stop.`)
635
+ const taskText = [t.title, t.description, t.type, t.kind, Array.isArray(t.labels) ? t.labels.join(' ') : t.labels].filter(Boolean).join(' ')
636
+ const kind = canCode && needsCode(taskText) ? 'full' : 'coord'
637
+ void drain(kind, `A task was just ${k === 'task:created' ? 'created and assigned' : 'assigned'} to an agent in this workspace: task #${t.id != null ? t.id : '?'}: "${t.title || ''}"${desc} (agent_id ${agentId}). Call get_marching_orders to confirm it is assigned to YOU. If it is yours, acknowledge it once, complete it with the tools appropriate to this ${kind === 'full' ? 'coding' : 'coordination'} lane, move the ticket when appropriate, and report the verified result. If it is not yours, do nothing and stop.`)
609
638
  } else if (k === 'agent:mention') {
610
639
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
611
640
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
@@ -648,10 +677,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
648
677
  // Light up the live status the instant we pick this up (thinking → the cycle
649
678
  // takes it to working → typing → done).
650
679
  if (cid != null) { statusTargets.add(cid); try { handle && handle.sendStatus(cid, 'thinking') } catch { /* best-effort */ } }
680
+ const codingMention = canCode && needsCode(text)
651
681
  const ctx = cid != null
652
- ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. Send EXACTLY ONE reply with post_message arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE do NOT post a first reply and then a revised/"better" one. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}" (a mention only links when the name matches exactly — "@${who.split(' ')[0]}" alone will NOT).` : ''} You ALREADY have the message here do NOT poll_inbox, and after your single reply, STOP.`
682
+ ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
653
683
  : undefined
654
- void drain('fast', ctx)
684
+ void drain(codingMention ? 'full' : 'fast', ctx)
655
685
  } else if (k === 'error') {
656
686
  // Surface the backend's rejection detail instead of a bare "event error".
657
687
  log('error event: ' + JSON.stringify(raw).slice(0, 220))
@@ -735,7 +765,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
735
765
  }
736
766
  // The first inbox response below decides whether a startup sweep is needed.
737
767
  // Do not spend a model turn on every service restart when the inbox is empty.
738
- setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
768
+ setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + COORDINATE }, 24 * 60 * 60 * 1000)
739
769
  }
740
770
 
741
771
  async function check() {
@@ -761,7 +791,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
761
791
  if (!busy && queuedSpecial) {
762
792
  const p = queuedSpecial; queuedSpecial = null
763
793
  busy = true
764
- try { await runCycle(p, model) } finally { busy = false; lastNewAt = Date.now() }
794
+ try { await runCycle(p, liteModel) } finally { busy = false; lastNewAt = Date.now() }
765
795
  await sleep(FAST)
766
796
  continue
767
797
  }
@@ -773,7 +803,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
773
803
  for (const i of items) if (i.startsWith('tk:') || i.startsWith('clm:')) seen.add(i)
774
804
  if (hasTaskBacklog) {
775
805
  log('startup task backlog -> catch-up sweep')
776
- queuedSpecial = SWEEP + '\n\n' + fullPrompt
806
+ queuedSpecial = SWEEP + '\n\n' + COORDINATE
777
807
  }
778
808
  }
779
809
  if (res.paused) {
@@ -815,7 +845,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
815
845
  // ── background service install (launchd / systemd) ───────────────────────────
816
846
  export function installService({ slug, workdir }) {
817
847
  const binPath = onPath('openvisio-agent')
818
- if (!binPath) {
848
+ if (!binPath || binPath.includes('/_npx/')) {
819
849
  info('Installing openvisio-agent globally so the background service has a stable path…')
820
850
  spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
821
851
  }