main-processes
Skill9 starsChanged 4 days agoMITView on GitHub
Patterns for external processes and native dependencies in the main process (git CLI, ripgrep, node-pty, language servers, file watchers) — spawning safely, lifecycle and cleanup, bundling ESM-only deps and unpacking native binaries. Use when creating or reviewing code in src/main that spawns a process, watches files, or adds a dependency to main.
In this repository17 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- app-testingSkill
- claude-panelSkill
- commands-settingsSkill
- containersSkill
- debuggerSkill
- files-git-searchSkill
- i18nSkill
- lspSkill
- main-ipcSkill
- packaging-releaseSkill
- renderer-componentsSkill
- renderer-editorSkill
- renderer-storeSkill
- renderer-stylesSkill
- renderer-terminalSkill
- workspacesSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 11
Put -- before paths and user-provided queries: git add -- <paths>, rg -- <query> ..
- Distinctive: line 41
Every long-lived process is owned by something (a window, a workspace) and killed when its owner goes away.
- Very specific: line 42
Every process a user action can restart (search, LSP request) must be killable: keep the reference, kill() the previous one when a new …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
External Processes & Native Dependencies (src/main)
Rules
- Always
execFile/spawnwith an argument array. Nevershell: true, never a command built from a string. - Put
--before paths and user-provided queries:git add -- <paths>,rg -- <query> .. - Processes that may spawn children (git network, docker, clone, language servers, the Claude CLI) use
spawnGroup()fromprocess/spawnGroup.ts: own process group on POSIX, global registry swept bykillAllSpawnedGroups()atwill-quit,kill()takes the whole tree (process/tree.ts; on WindowskillAllSpawnedGroupssends every non-graceful tree in ONEtaskkill /pid a /pid b /T /FviakillLiveTreesSync). Neverproc.kill()such a process: it orphansssh/helpers.proc.closedresolves when it exits (or never started). LSP (lsp/server.ts), debug adapters (debug/transport.ts) and Claude (claude/spawn.ts) spawn throughspawnGrouptoo; what is their own is only the policy on top (LSP restart/backoff/shutdown handshake, the DAPterminate/disconnectgrace, Claude stderr tail and the SDK abort). - A command that runs to the end and whose output is read (git, docker one-shots,
claude mcp/auth,rg,lsof,osascript) goes throughrunCollect(process/runCollect.ts), never a hand-rolledspawn/execFilewith its own timer,timedOutflag, buffers and kill. It never rejects: it returns{ code, stdout, stderr, timedOut, cancelled, spawnError, overflow }and the caller only classifies it into translated errors. Options:timeout;maxBuffer+onOverflow('kill'default = failure, or'truncate': the stdout never goes past the cap,overflowsays it did);maxStderr+stderrKeep('tail'default or'head'; never a failure);group: true(aspawnGroup, tree killed by timeout/cancel/will-quit; default is a plain process) andgraceful;binary(stdout asBuffer);onStdout(chunk, stop)to stream text instead of collecting it; cancellation bysignal(AbortSignal) and/orowner(registry.scope(wc)of acreateViewRegistry<GroupProcess>: the process is tracked in the view, a reset kills it andcancelledtells it apart from a timeout). Exceptions that stay onspawnGroup: streaming processes (dockerstats/logs -f, LSP, Claude SDK) andenv/loginShell.ts(injectable spawner, stdin closed by hand). - Native binaries run by another process (
rg, the Claude CLI, language-server scripts) get their path throughunpackedPath(util/unpackedPath.ts; only a realapp.asarpath segment is rewritten, nevermy-app.asar). The login-shell PATH (env/loginShell.ts) is spawned throughspawnGroup, cached per shell only on success (after a failure callers getnullat once and one background retry runs with exponential backoff). - A pty (
terminal/create.ts, held interminal/registry.ts;ipc/terminal.tsonly wires channels) disposes itsonData/onExitlisteners with the terminal. Closing a terminal or resetting its view is a plainkill()(SIGHUP): the shell resends HUP to its jobs, andnohup/disownjobs survive on purpose, like in VS Code. Only the app quit sweeps:killPtysSyncAndSweep(terms)(process/ptySession.ts, POSIX only, no-op on Windows) snapshots the shell sessions BEFORE the HUP (Linux:/procsession id; macOS:ps -t-style tty of the ptyptsName), sends HUP, waits up to 1 s only when members exist, then SIGKILLs the survivors whose start time still matches (PID reuse). Never SIGKILL the shell right after HUP (jobs would be orphaned). Daemons thatsetsid/drop the tty are excluded on purpose. Everypsruns withLC_ALL=C(lstartis localized otherwise). - Every long-lived process is owned by something (a window, a workspace) and killed when its owner goes away.
- Every process a user action can restart (search, LSP request) must be killable: keep the reference,
kill()the previous one when a new request arrives. - Shared primitives (use them, never write them inline):
- Deadline on a promise:
util/deadline.tswithDeadline(p, ms, { signal? })(resolvesTIMEOUTon expiry, a rejection ofpis passed through) orrejectAfter(p, ms, makeError, { signal? }); the timer is always cleared. No hand-rolledsetTimeout+Promise.race. The timeout of a child process is thetimeoutoption ofrunCollect, not a promise deadline. - Serial work:
util/serialQueue.ts(SerialQueue,KeyedSerialQueue); never chaintail = tail.then(...)by hand. - Lines from a stream:
util/lineSplitter.tscreateLineSplitter(maxLine?)(CRLF aware, an endless line is emitted at the cap,flush()at the end); never(tail + chunk).split('\n'). - Child environment:
env/childEnv.tsbuildChildEnv(base, extra, pathVar, { stripPrefixes })(always dropsELECTRON_RUN_AS_NODE) andfindInPath; the PATH comes fromenv/loginShell.tseffectivePath(). - User-configured executable:
env/executable.tsresolveExecutable(value, pathVar, { messages })(absolute path must exist, name is looked up on the PATH,.cmd/.batrefused on Windows, errors carrycode: 'ENOENT') andassertRunnableBinary(file, message)(refuses.js/.mjs/.cjs).docker/**must not import fromclaude/**.
- Deadline on a promise:
// ✅ CORRECT
execFile('git', ['commit', '-m', message], { cwd: root })
// ❌ WRONG — shell injection through the commit message
exec(`git commit -m "${message}"`, { cwd: root })Quick Index
| Tool | Where | Notes |
| git | src/main/git/run.ts → git(cwd, args) | Sets GIT_TERMINAL_PROMPT=0 (never hangs waiting for a password) and GIT_OPTIONAL_LOCKS=0. Porcelain paths are relative to the repo root, not the workspace: the root comes from rev-parse --show-cdup resolved against the window folder, never --show-toplevel (details in the files-git-search skill). Decide by exit code (check-ignore: 0/1/128) with gitStatusCode(cwd, args, { timeoutMs }), never a second execFile('git'). |
| ripgrep | src/main/search/rg.ts (used by ipc/search.ts and files/fileListCache.ts) | runRg(args, { cwd, owner, onStdout }) prepends --no-config --hidden --glob=!.git, keeps the head of stderr (8 KiB) and returns an outcome (ok: exit 0/1 or stopped by the caller; error: exit 2; interrupted: killed by signal; cancelled; spawnError); describeRgSpawnError(err, messages) picks ENOENT/EACCES text. Binary from @vscode/ripgrep (rgPath.ts). Offsets in --json output are UTF-8 bytes: convert to chars. |
| node-pty | src/main/terminal/create.ts (+ registry.ts, shellCwd.ts, links.ts) | Login shell (-l) on macOS/Linux so the user's PATH loads; powershell.exe on Windows. Removes ELECTRON_RUN_AS_NODE from the child env. |
| @parcel/watcher | src/main/workspace/treeWatcher.ts (per-view controller with injectable subscribe; viewWatchers.ts wires the real deps) | Native recursive watcher (FSEvents/inotify/ReadDirectoryChangesW) on the realpath of the folder (paths mapped back to the window spelling); ignore globs = floor + files.watcherExclude only, never files.exclude (workspace/watchFilter.ts); batches 150 ms (2 s for inactive views), workspace/eventBatch.ts classifies file/dir with lstat. The settings files use fs.watch on their directory (settings/dirWatch.ts). |
Process output and text
- Output of
docker,git, the Claude CLI or a pty is untrusted text:stripAnsiremoves CSI and OSC escapes,stripControl(text, { keepNewlines })keeps multi-line text (\nand\tsurvive,\rbecomes\n),flattenControlturns control runs into one space (callerstrim()/collapse themselves) andhasControlvalidates (all inshared/controlChars.ts). Never write these regexes inline. Guards forunknownvalues:shared/guards.ts.
Lifecycle
// window/viewResources.ts: every process/session/manager owned by a view lives in a registry, never in a hand-rolled Map
const terminals = createViewRegistry<Terminal>({
key: 'terminal', // onViewReset key; the reset hook is armed by add/track/guardEpoch
dispose: (t) => t.term.kill(), // reset, dispose(), disposeAll(); the item is already out and wasCancelled()
disposeSync: (all) => killAll(all) // optional batch for will-quit; defaults to dispose per item
})
terminals.add(sender, id, t) // get(wc, id) is owner-checked: a foreign view finds nothingcreateViewRegistryAPI:add(wc, id, item)/track(wc, item)(no id, returns the untrack),get,values(wc),entries()(all views),has(wc)(eviction guards),delete(wc, id, expected?)(finished by itself, no dispose),dispose(wc, id),disposeAll(wc),disposeAllSync()(call it from the domain'skillAll…forwill-quit),markCancelled/wasCancelled(owner reset vs timeout/failure, picks the error message) andguardEpoch(wc): call it BEFORE anawaitthat precedes registering the item, thenassertAlive(() => new UserError(t()…))after it. One item per view (clone, LSP manager) = a fixed id; a search is one item per request, tracked throughsearches.scope(owner)(runRg'sowner). Never keep aMap<WebContents, …>or a cancelledWeakSetby hand. Exception: state that is not a disposable resource (watchers/generations inworkspace/treeWatcher.ts,WeakMapcaches) and the dockerViewState(stats/logs runs, lastps) stay as they are.- Reset vs destroyed:
onViewResetfor resources that die with a reload or renderer crash too (processes, watchers);onViewDestroyed(wc, key, cb)for what only makes sense until the view is gone. Both fromwindow/viewLifecycle.ts. will-quitinsrc/main/index.tsstops everything global (neverbefore-quit: it fires even when the dirty guard then cancels the quit).- Per-view state: key it by the view's
webContents(see theworkspacesskill).
Dependencies in main
- main is bundled as CommonJS with dependencies externalized (
electron-vitedefault). - ESM-only package that breaks with
require→ add it tomain.build.externalizeDeps.excludeinelectron.vite.config.tsso it gets bundled (none today).@vscode/ripgrepis ESM but works externalized thanks to Node 24'srequire(esm). - Native binaries (node-pty, ripgrep, anything with a
.nodeor executable) must be listed inasarUnpackinelectron-builder.yml, and their path rewritten with.replace('app.asar', 'app.asar.unpacked'). - Native modules are rebuilt for Electron by the
postinstall(electron-builder install-app-deps). Never skip it.
Cross-platform
- Paths: always
node:path(join,dirname,relative) in main. The renderer gets paths already in native format. - Shells and executables differ per OS (
rg.exe,powershell.exe): never hardcode POSIX-only commands. - macOS apps opened from Finder get a minimal PATH (resolved by
src/main/env/loginPath.ts).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

