@commonlyai/cli 0.1.223 → 0.1.224

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.
@@ -82,7 +82,9 @@ export const leadsOwnGroup = (child) => !!child
82
82
 
83
83
  // ── The process table ──────────────────────────────────────────────────────
84
84
 
85
- const PS_ENV = { ...process.env, LC_ALL: 'C' };
85
+ // lstart is printed in local time with no offset, which repeats an hour when
86
+ // daylight saving ends. In UTC it never repeats; `startedMs` reads it as UTC.
87
+ const PS_ENV = { ...process.env, LC_ALL: 'C', TZ: 'UTC' };
86
88
 
87
89
  const parseTable = (text) => {
88
90
  const rows = [];
@@ -100,6 +102,9 @@ const ps = (args) => {
100
102
  } catch { return null; }
101
103
  };
102
104
 
105
+ /** A row's `started` as epoch ms. */
106
+ export const startedMs = (started) => Date.parse(`${started} GMT`);
107
+
103
108
  /** Every process: { pid, ppid, started }. null when ps is unavailable. */
104
109
  export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
105
110
 
@@ -107,6 +112,9 @@ export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
107
112
  export const readProcess = (pid) => (ps(['-o', 'pid=,ppid=,lstart=', '-p', String(pid)]) || [])
108
113
  .find((row) => row.pid === pid) || null;
109
114
 
115
+ /** Several processes as they are now, in one ps call; a gone pid has no row. null when ps is unavailable. */
116
+ export const readProcesses = (pids) => (pids.length ? ps(['-o', 'pid=,ppid=,lstart=', '-p', pids.join(',')]) : []);
117
+
110
118
  /** The descendants of `root` in `table`, deepest first. The root is not included. */
111
119
  export const descendantsOf = (table, root) => {
112
120
  const byParent = new Map();
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Processes a turn leaves running.
3
+ *
4
+ * A turn can start work that outlives it. A dev server backgrounded from a
5
+ * shell tool (`npm exec vite &`) is orphaned the moment that shell exits and
6
+ * is reparented to launchd, where no group kill or descendant walk from the
7
+ * turn's child can reach it. Six such trees, one to four days old, took the
8
+ * fleet host's swap to 12 GB (SRE, 2026-11-01).
9
+ *
10
+ * So a turn's child is started with a fresh marker in its environment, which
11
+ * every process it starts inherits however it detaches. When the child has
12
+ * closed, the processes still carrying the marker are stopped: SIGTERM, then
13
+ * SIGKILL after a grace for any still alive. That includes a lasting server
14
+ * the turn started on first use, such as tmux, and a seat it restarted. A
15
+ * process started with COMMONLY_KEEP_PROCESS=1 is left running, and so is
16
+ * anything it starts, even one that clears the setting; the turn's child itself
17
+ * never inherits it. COMMONLY_TURN_REAP=0 turns the whole check off.
18
+ *
19
+ * Only processes started since the turn began are read, and each one's
20
+ * environment is only compared against the marker and the keep setting. Each
21
+ * pid is re-read just before it is signalled, and is signalled only while its
22
+ * start time is still the one recorded, so a reused pid is left alone.
23
+ *
24
+ * What it does not catch: on macOS `ps -E` cannot read the environment of an
25
+ * Apple platform binary (sh, bash, sleep), so one of those is stopped only as
26
+ * a descendant of a marked process. A process that clears its environment
27
+ * loses the marker. Both are left running. The same unreadable environment
28
+ * hides a keep setting: a kept Apple binary is not seen as kept, so a process
29
+ * it starts is kept only if that process keeps the setting itself.
30
+ */
31
+
32
+ import { randomUUID } from 'node:crypto';
33
+ import { readFileSync } from 'node:fs';
34
+ import { spawnSync } from 'node:child_process';
35
+ import {
36
+ PROCESS_GROUPS_SUPPORTED, readProcesses, readProcessTable, startedMs,
37
+ } from './process-group.js';
38
+
39
+ export const TURN_MARK_ENV = 'COMMONLY_TURN_ID';
40
+ export const KEEP_PROCESS_ENV = 'COMMONLY_KEEP_PROCESS';
41
+ export const TURN_REAP_ENV = 'COMMONLY_TURN_REAP';
42
+ export const TURN_REAP_GRACE_MS = 5_000;
43
+ // How far before the turn a process's recorded start may read and still be
44
+ // looked at. lstart has one-second resolution, and on Linux ps derives it from
45
+ // the boot time, which drifts from the wall clock by a second or more. The
46
+ // window only bounds what is read; the marker decides.
47
+ export const TURN_START_SLACK_MS = 60_000;
48
+
49
+ export const turnReapEnabled = (env = process.env) => PROCESS_GROUPS_SUPPORTED && env?.[TURN_REAP_ENV] !== '0';
50
+
51
+ const PS_ENV = { ...process.env, LC_ALL: 'C' };
52
+
53
+ // pid -> the set of tokens its environment reads as. A pid whose environment
54
+ // could not be read has no entry, or a set without NAME=value pairs.
55
+ const readEnvironmentsDarwin = (pids) => {
56
+ const out = new Map();
57
+ const res = spawnSync('ps', ['-E', '-ww', '-o', 'pid=,command=', '-p', pids.join(',')], {
58
+ encoding: 'utf8', env: PS_ENV, timeout: 5000, maxBuffer: 64 * 1024 * 1024,
59
+ });
60
+ if (res.error) return out;
61
+ for (const line of String(res.stdout || '').split('\n')) {
62
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
63
+ if (match) out.set(Number(match[1]), new Set(match[2].split(/\s+/)));
64
+ }
65
+ return out;
66
+ };
67
+
68
+ const readEnvironmentsLinux = (pids) => {
69
+ const out = new Map();
70
+ for (const pid of pids) {
71
+ try {
72
+ out.set(pid, new Set(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')));
73
+ } catch { /* gone, or not readable */ }
74
+ }
75
+ return out;
76
+ };
77
+
78
+ export const readEnvironments = (pids) => {
79
+ if (!pids.length || !PROCESS_GROUPS_SUPPORTED) return new Map();
80
+ return process.platform === 'linux' ? readEnvironmentsLinux(pids) : readEnvironmentsDarwin(pids);
81
+ };
82
+
83
+ const selfAndAncestors = (table, pid) => {
84
+ const byPid = new Map(table.map((row) => [row.pid, row]));
85
+ const out = new Set([pid]);
86
+ for (let row = byPid.get(pid); row && row.ppid > 0 && !out.has(row.ppid); row = byPid.get(row.ppid)) {
87
+ out.add(row.ppid);
88
+ }
89
+ return out;
90
+ };
91
+
92
+ /**
93
+ * The processes still carrying `mark`, as { pid, ppid, started }: started at
94
+ * or after `since` (ms), neither this process nor an ancestor of it, and
95
+ * neither kept nor under a kept one. Each one brings its descendants, except a
96
+ * kept one and everything
97
+ * under it, whose environments may be unreadable. The start-time window only
98
+ * limits what is read; the marker is what decides. `io` is a test seam
99
+ * ({ table, envs, self }).
100
+ */
101
+ export const findTurnProcesses = (mark, since, io = {}) => {
102
+ const { table = readProcessTable, envs = readEnvironments, self = process.pid } = io;
103
+ if (!mark) return [];
104
+ const rows = table();
105
+ if (!rows) return [];
106
+ const skip = selfAndAncestors(rows, self);
107
+ const floor = Number(since) - TURN_START_SLACK_MS;
108
+ const recent = rows.filter((row) => !skip.has(row.pid) && startedMs(row.started) >= floor);
109
+ const environments = envs(recent.map((row) => row.pid));
110
+ const marked = `${TURN_MARK_ENV}=${mark}`;
111
+ const keep = `${KEEP_PROCESS_ENV}=1`;
112
+ const kept = (pid) => environments.get(pid)?.has(keep) === true;
113
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
114
+ // A kept process keeps what it starts even when that drops the setting.
115
+ const underKept = (row) => {
116
+ for (let up = byPid.get(row.ppid), seen = new Set(); up && !seen.has(up.pid); up = byPid.get(up.ppid)) {
117
+ if (kept(up.pid)) return true;
118
+ seen.add(up.pid);
119
+ }
120
+ return false;
121
+ };
122
+ const found = new Map();
123
+ for (const row of recent) {
124
+ if (environments.get(row.pid)?.has(marked) && !kept(row.pid) && !underKept(row)) found.set(row.pid, row);
125
+ }
126
+ const byParent = new Map();
127
+ for (const row of rows) {
128
+ if (row.pid === row.ppid) continue;
129
+ if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
130
+ byParent.get(row.ppid).push(row);
131
+ }
132
+ for (const stack = [...found.keys()]; stack.length;) {
133
+ for (const child of byParent.get(stack.pop()) || []) {
134
+ if (found.has(child.pid) || skip.has(child.pid) || kept(child.pid)) continue;
135
+ found.set(child.pid, child);
136
+ stack.push(child.pid);
137
+ }
138
+ }
139
+ return [...found.values()];
140
+ };
141
+
142
+ /**
143
+ * Stop what `findTurnProcesses` returns. SIGTERM is sent before this returns;
144
+ * `done` settles after `graceMs`, once anything still alive has had SIGKILL.
145
+ * A pid is signalled only while its start time is the one recorded, read for
146
+ * all of them in one ps call per signal. The grace timer holds the process
147
+ * open until SIGKILL has gone out, so a seat stopped mid-turn still escalates.
148
+ * `io` adds `read` (pids -> rows) to the find seam.
149
+ */
150
+ export const reapTurnProcesses = (mark, since, { graceMs = TURN_REAP_GRACE_MS, io = {} } = {}) => {
151
+ const read = io.read || readProcesses;
152
+ const signal = (rows, name) => {
153
+ const now = new Map((read(rows.map((row) => row.pid)) || []).map((row) => [row.pid, row]));
154
+ return rows.filter((row) => {
155
+ if (now.get(row.pid)?.started !== row.started) return false;
156
+ try { process.kill(row.pid, name); return true; } catch { return false; }
157
+ });
158
+ };
159
+ const termed = signal(findTurnProcesses(mark, since, io), 'SIGTERM');
160
+ const done = termed.length === 0
161
+ ? Promise.resolve({ killed: [] })
162
+ : new Promise((resolve) => {
163
+ setTimeout(() => resolve({ killed: signal(termed, 'SIGKILL').map((row) => row.pid) }), graceMs);
164
+ });
165
+ return { termed: termed.map((row) => row.pid), done };
166
+ };
167
+
168
+ /**
169
+ * One turn's marker. `env` is the child's environment with the marker added;
170
+ * `reap(child)` runs once the child has closed, and returns what
171
+ * `reapTurnProcesses` does, or null when the check is off, the child never had
172
+ * a pid, or the turn was already reaped.
173
+ */
174
+ export const markTurn = (env, { enabled = turnReapEnabled(), now = Date.now, graceMs } = {}) => {
175
+ if (!enabled) return { env, mark: null, reap: () => null };
176
+ const mark = randomUUID();
177
+ const since = now();
178
+ // A keep setting the seat itself was started with would keep every turn.
179
+ const childEnv = { ...env, [TURN_MARK_ENV]: mark };
180
+ delete childEnv[KEEP_PROCESS_ENV];
181
+ let reaped = false;
182
+ return {
183
+ env: childEnv,
184
+ mark,
185
+ reap: (child, { io } = {}) => {
186
+ if (reaped || !Number.isInteger(child?.pid) || child.pid <= 0) return null;
187
+ reaped = true;
188
+ try { return reapTurnProcesses(mark, since, { graceMs, io }); } catch { return null; }
189
+ },
190
+ };
191
+ };
192
+
193
+ /** The log line for a reap that stopped something, or null. */
194
+ export const describeTurnReap = (result) => {
195
+ if (!result?.termed?.length) return null;
196
+ const n = result.termed.length;
197
+ return `stopped ${n} process${n === 1 ? '' : 'es'} this turn left running (pid ${result.termed.join(', ')}); `
198
+ + `start one with ${KEEP_PROCESS_ENV}=1 to keep it across turns`;
199
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.223",
3
+ "version": "0.1.224",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
8
8
  } from '@commonlyai/seats/process-group';
9
+ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
9
10
  /**
10
11
  * claude adapter — wraps the local `claude` CLI as a Commonly agent.
11
12
  *
@@ -92,7 +93,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent
92
93
  import {
93
94
  prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
94
95
  } from '../mcp-home.js';
95
- import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
96
+ import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
96
97
  import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
97
98
  import { skillsAsText } from '../skill-prompt.js';
98
99
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
@@ -115,13 +116,14 @@ const buildPrompt = buildMemoryPreamble;
115
116
 
116
117
  // The working habits a private claude seat is taught on a fresh session
117
118
  // (TASK-545). Parallel reads live here because only this CLI has a subagent
118
- // tool; codex is passed row slicing alone. A public seat is passed neither: its
119
- // argv allows Read and the MCP under dontAsk, so it has no subagent to spawn,
120
- // and it answers strangers rather than working board rows.
119
+ // tool; codex is passed row slicing and the background-process cue. A public
120
+ // seat is passed none: its argv allows Read and the MCP under dontAsk, so it
121
+ // has no subagent to spawn or shell to background from, and it answers
122
+ // strangers rather than working board rows.
121
123
  const PARALLEL_READS_CUE = 'Parallel reads: when a row needs several files or sources read, '
122
124
  + 'use the Agent tool. Spawn read-only subagents in parallel, brief each one with the files '
123
125
  + 'to read and the question to answer, and keep only their conclusions.';
124
- const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE];
126
+ const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE];
125
127
  const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
126
128
 
127
129
  const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
@@ -635,11 +637,14 @@ export const claudeFailureTail = (stdout) => {
635
637
  const runClaude = ({
636
638
  cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
637
639
  transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
638
- processTable = readProcessTable, survivorIo, modelStream = true,
640
+ processTable = readProcessTable, survivorIo, modelStream = true, turnReap = turnReapEnabled(),
639
641
  }) => new Promise((resolve, reject) => {
642
+ // Every process the turn starts inherits this marker however it detaches,
643
+ // so what it leaves running is stopped once the turn has closed.
644
+ const turn = markTurn(env ?? process.env, { enabled: turnReap });
640
645
  const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
641
646
  cwd,
642
- env,
647
+ env: turn.env,
643
648
  stdio: ['ignore', 'pipe', 'pipe'],
644
649
  }, { ownGroup });
645
650
  observeSpawn(proc, onSpawn, controls);
@@ -711,6 +716,9 @@ const runClaude = ({
711
716
  clearTimeout(timer);
712
717
  clearTimeout(stallKill);
713
718
  watchdog.stop();
719
+ const reaped = describeTurnReap(turn.reap(proc));
720
+ // eslint-disable-next-line no-console
721
+ if (reaped) console.warn(`[claude] ${reaped}`);
714
722
  if (stalled) {
715
723
  sweepStallSurvivors();
716
724
  return reject(stalled);
@@ -1418,6 +1426,7 @@ export default {
1418
1426
  stallKillGraceMs: ctx._stallKillGraceMs,
1419
1427
  processTable: ctx._processTable,
1420
1428
  survivorIo: ctx._survivorIo,
1429
+ turnReap: ctx._turnReap,
1421
1430
  });
1422
1431
  // TASK-440: the context this turn left, read from the transcript the
1423
1432
  // child wrote under its own env (an isolated HOME for public seats).
@@ -1486,6 +1495,7 @@ export default {
1486
1495
  stallKillGraceMs: ctx._stallKillGraceMs,
1487
1496
  processTable: ctx._processTable,
1488
1497
  survivorIo: ctx._survivorIo,
1498
+ turnReap: ctx._turnReap,
1489
1499
  });
1490
1500
  const contextTokens = claudeContextTokens({
1491
1501
  cwd: physicalPath(ctx.cwd), sessionId: freshId,
@@ -1,6 +1,7 @@
1
1
  import { codexRuntimeInventory } from '../native-runtime-inventory.js';
2
2
  import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
3
3
  import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
4
+ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
4
5
  /**
5
6
  * codex adapter — wraps the local `codex` CLI as a Commonly agent.
6
7
  *
@@ -73,7 +74,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
73
74
  import { supportedGitHooks } from '../commit-attribution.js';
74
75
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
75
76
  import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
76
- import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
77
+ import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
77
78
  import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
78
79
  import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
79
80
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
@@ -731,14 +732,19 @@ const CODEX_PROVIDER_KEY_ENVS = [
731
732
  ];
732
733
 
733
734
  // `ownGroup`: see runClaude in claude.js.
734
- const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
735
+ const runCodex = ({
736
+ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
737
+ turnReap = turnReapEnabled(),
738
+ }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
735
739
  // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
736
740
  // `Reading additional input from stdin...` because it sees an open pipe
737
741
  // and waits for input that never arrives. Interactive runs are fine because
738
742
  // codex detects a TTY and uses the argv prompt directly. Setting stdin to
739
743
  // `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
740
744
  // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
741
- const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
745
+ // What the turn leaves running is stopped once it has closed (see claude.js).
746
+ const turn = markTurn(env ?? process.env, { enabled: turnReap });
747
+ const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env: turn.env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
742
748
  observeSpawn(proc, onSpawn, controls);
743
749
  let stderr = '';
744
750
  let timedOut = false;
@@ -757,6 +763,9 @@ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = chi
757
763
  });
758
764
  proc.on('close', (code) => {
759
765
  clearTimeout(timer);
766
+ const reaped = describeTurnReap(turn.reap(proc));
767
+ // eslint-disable-next-line no-console
768
+ if (reaped) console.warn(`[codex] ${reaped}`);
760
769
  if (timedOut) return reject(new Error(`codex timed out after ${timeoutMs}ms`));
761
770
  if (events.turnFailedMessage) {
762
771
  // Surface the model-side failure message verbatim — the run loop posts
@@ -823,7 +832,7 @@ export default {
823
832
  // answers strangers rather than working board rows, so it is taught neither.
824
833
  const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
825
834
  freshSession: !ctx.sessionId,
826
- workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE],
835
+ workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE],
827
836
  });
828
837
 
829
838
  // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
@@ -917,6 +926,7 @@ export default {
917
926
  credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
918
927
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
919
928
  ownGroup: wantsOwnProcessGroup(ctx),
929
+ turnReap: ctx._turnReap,
920
930
  });
921
931
 
922
932
  let text = '';
@@ -20,6 +20,8 @@
20
20
  * them from the client is wasted bytes and the kernel discards them.
21
21
  */
22
22
 
23
+ import { KEEP_PROCESS_ENV } from '@commonlyai/seats/turn-processes';
24
+
23
25
  export const SOURCE_RUNTIME = 'local-cli';
24
26
 
25
27
  /**
@@ -34,6 +36,13 @@ export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, s
34
36
  + "to the row's taskId) with one assignee, a written file or area boundary, and its own "
35
37
  + 'acceptance line.';
36
38
 
39
+ // The turn's leftovers are stopped when it ends (@commonlyai/seats/turn-processes),
40
+ // so a seat that means to reuse a server has to say so when it starts it.
41
+ export const BACKGROUND_PROCESS_CUE = 'Background processes: anything you start in the background is '
42
+ + 'stopped when this turn ends, including a server that starts on first use, such as tmux, and an agent you '
43
+ + 'restart with nohup. To keep one for a later turn, such as a dev server you will reuse, start it '
44
+ + `with ${KEEP_PROCESS_ENV}=1 set, and stop it yourself when you are done with it.`;
45
+
37
46
  /**
38
47
  * The turn preamble, shared by every adapter so the two cannot drift.
39
48
  *