@crouton-kit/crouter 0.3.3 → 0.3.8

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/dist/core/jobs.js CHANGED
@@ -6,7 +6,9 @@
6
6
  // Layout: ${XDG_STATE_HOME or ~/.local/state}/crtr/jobs/<job_id>/
7
7
  // meta.json — written atomically on create; updated atomically on terminal transition.
8
8
  // log.jsonl — append-only event log.
9
- // result.json written atomically; its APPEARANCE is the only completion signal.
9
+ // result.md agent submissions (markdown body + YAML frontmatter). Written atomically.
10
+ // result.json — programmatic submissions (structured object). Written atomically.
11
+ // Either result file's APPEARANCE is the completion signal. Exactly one is written per job.
10
12
  import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
11
13
  import { watch } from 'node:fs';
12
14
  import { spawnSync } from 'node:child_process';
@@ -31,9 +33,22 @@ function metaPath(jobId) {
31
33
  function logPath(jobId) {
32
34
  return join(jobDir(jobId), 'log.jsonl');
33
35
  }
34
- function resultPath(jobId) {
36
+ function resultJsonPath(jobId) {
35
37
  return join(jobDir(jobId), 'result.json');
36
38
  }
39
+ function resultMdPath(jobId) {
40
+ return join(jobDir(jobId), 'result.md');
41
+ }
42
+ /** Path of whichever result file currently exists, or null if neither does. */
43
+ function existingResultPath(jobId) {
44
+ const md = resultMdPath(jobId);
45
+ if (existsSync(md))
46
+ return md;
47
+ const js = resultJsonPath(jobId);
48
+ if (existsSync(js))
49
+ return js;
50
+ return null;
51
+ }
37
52
  // ---------------------------------------------------------------------------
38
53
  // Internal helpers
39
54
  // ---------------------------------------------------------------------------
@@ -129,9 +144,9 @@ export function appendEvent(jobId, event) {
129
144
  appendFileSync(p, JSON.stringify(line) + '\n', 'utf8');
130
145
  }
131
146
  /**
132
- * Atomically write result.json and update meta.json status.
133
- * result.json's appearance is the ONLY completion signal never inferred from
134
- * log content.
147
+ * Atomically write result.json (structured object) and update meta.json status.
148
+ * Used by programmatic callers (human, sys) that produce object results.
149
+ * The result file's appearance is the completion signal — never inferred from log content.
135
150
  */
136
151
  export function writeResult(jobId, result, terminalStatus) {
137
152
  const dir = jobDir(jobId);
@@ -143,44 +158,124 @@ export function writeResult(jobId, result, terminalStatus) {
143
158
  result,
144
159
  written_at: new Date().toISOString(),
145
160
  };
146
- // Atomic write: tmp + rename within same directory (same fs, rename is atomic).
147
161
  const tmp = join(dir, '.result.tmp');
148
162
  writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
149
- renameSync(tmp, resultPath(jobId));
150
- // Update meta status.
163
+ renameSync(tmp, resultJsonPath(jobId));
164
+ const meta = readMeta(jobId);
165
+ meta.status = terminalStatus;
166
+ writeMeta(jobId, meta);
167
+ }
168
+ /**
169
+ * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
170
+ * Used by `crtr job submit` for agent-driven markdown results.
171
+ */
172
+ export function writeMarkdownResult(jobId, body, terminalStatus, reason) {
173
+ const dir = jobDir(jobId);
174
+ if (!existsSync(dir)) {
175
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
176
+ }
177
+ const fm = {
178
+ status: terminalStatus,
179
+ written_at: new Date().toISOString(),
180
+ };
181
+ if (reason !== undefined && reason !== '') {
182
+ fm.reason = reason;
183
+ }
184
+ const content = `${renderFrontmatter(fm)}${body}`;
185
+ const tmp = join(dir, '.result.tmp');
186
+ writeFileSync(tmp, content, 'utf8');
187
+ renameSync(tmp, resultMdPath(jobId));
151
188
  const meta = readMeta(jobId);
152
189
  meta.status = terminalStatus;
153
190
  writeMeta(jobId, meta);
154
191
  }
155
192
  /**
156
- * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
157
- * until result.json appears or the timeout elapses.
158
- *
159
- * Race safety: registers the watcher THEN re-stats. If result.json appeared
160
- * between the first stat and the watch registration, the re-stat catches it
161
- * before the watcher has a chance to miss it.
193
+ * Render a small fixed-shape frontmatter block. We control writer and reader,
194
+ * so a 3-key hand-rolled emitter is plenty — no YAML dep, no escaping surprises.
195
+ * Values are plain strings; we double-quote `reason` to survive newlines/colons.
162
196
  */
197
+ function renderFrontmatter(fm) {
198
+ const lines = ['---', `status: ${fm.status}`, `written_at: ${fm.written_at}`];
199
+ if (fm.reason !== undefined) {
200
+ const escaped = fm.reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
201
+ lines.push(`reason: "${escaped}"`);
202
+ }
203
+ lines.push('---', '');
204
+ return lines.join('\n');
205
+ }
206
+ /**
207
+ * Parse the small fixed-shape frontmatter we emit. Tolerant of trailing
208
+ * whitespace; returns `{ frontmatter, body }`. Throws if the document does not
209
+ * start with `---\n` or no closing `---` is found.
210
+ */
211
+ function parseMarkdownResult(raw) {
212
+ if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
213
+ throw new Error('result.md missing opening --- delimiter');
214
+ }
215
+ const afterOpen = raw.indexOf('\n') + 1;
216
+ const closeIdx = raw.indexOf('\n---', afterOpen);
217
+ if (closeIdx === -1) {
218
+ throw new Error('result.md missing closing --- delimiter');
219
+ }
220
+ const fmBlock = raw.slice(afterOpen, closeIdx);
221
+ // Body starts after the closing `---` line.
222
+ const afterCloseLine = raw.indexOf('\n', closeIdx + 1);
223
+ const body = afterCloseLine === -1 ? '' : raw.slice(afterCloseLine + 1);
224
+ const fm = {};
225
+ for (const line of fmBlock.split('\n')) {
226
+ const m = line.match(/^([a-z_]+):\s*(.*)$/);
227
+ if (m === null)
228
+ continue;
229
+ const key = m[1];
230
+ if (m[2] === undefined)
231
+ continue;
232
+ let val = m[2];
233
+ if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
234
+ val = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
235
+ }
236
+ if (key === 'status') {
237
+ fm.status = val;
238
+ }
239
+ else if (key === 'written_at') {
240
+ fm.written_at = val;
241
+ }
242
+ else if (key === 'reason') {
243
+ fm.reason = val;
244
+ }
245
+ }
246
+ if (fm.status === undefined || fm.written_at === undefined) {
247
+ throw new Error('result.md frontmatter missing status or written_at');
248
+ }
249
+ return { frontmatter: fm, body };
250
+ }
163
251
  export function readResult(jobId, opts = {}) {
164
252
  const dir = jobDir(jobId);
165
253
  if (!existsSync(dir)) {
166
254
  throw notFound(`job not found: ${jobId}`, { job_id: jobId });
167
255
  }
168
- function parseResult() {
169
- const raw = readFileSync(resultPath(jobId), 'utf8');
256
+ function parseAt(path) {
257
+ const raw = readFileSync(path, 'utf8');
258
+ if (path.endsWith('.md')) {
259
+ const { frontmatter, body } = parseMarkdownResult(raw);
260
+ const out = { status: frontmatter.status, result_md: body };
261
+ if (frontmatter.reason !== undefined) {
262
+ out.reason = frontmatter.reason;
263
+ }
264
+ return out;
265
+ }
170
266
  const parsed = JSON.parse(raw);
171
267
  return { status: parsed.status, result: parsed.result };
172
268
  }
173
- // Fast path: result already present.
174
- if (existsSync(resultPath(jobId))) {
175
- const r = parseResult();
176
- return Promise.resolve({ status: r.status, result: r.result });
269
+ const existing = existingResultPath(jobId);
270
+ if (existing !== null) {
271
+ return Promise.resolve(parseAt(existing));
177
272
  }
178
273
  if (opts.waitMs === undefined || opts.waitMs <= 0) {
179
274
  return Promise.resolve({ status: 'timeout' });
180
275
  }
181
276
  return new Promise((resolve) => {
182
277
  let settled = false;
183
- const finish = (status, result) => {
278
+ const finish = (response) => {
184
279
  if (settled)
185
280
  return;
186
281
  settled = true;
@@ -189,41 +284,47 @@ export function readResult(jobId, opts = {}) {
189
284
  watcher.close();
190
285
  }
191
286
  catch { /* noop */ }
192
- resolve({ status, result });
287
+ resolve(response);
193
288
  };
194
- // Register watcher first, then re-stat (race safety).
195
289
  const watcher = watch(dir, (_event, name) => {
196
- if (name === 'result.json' && existsSync(resultPath(jobId))) {
197
- const r = parseResult();
198
- finish(r.status, r.result);
290
+ if (name !== 'result.md' && name !== 'result.json')
291
+ return;
292
+ const path = existingResultPath(jobId);
293
+ if (path !== null) {
294
+ finish(parseAt(path));
199
295
  }
200
296
  });
201
- // Re-stat after watcher is registered to close the race window.
202
- if (existsSync(resultPath(jobId))) {
203
- const r = parseResult();
204
- finish(r.status, r.result);
297
+ const path = existingResultPath(jobId);
298
+ if (path !== null) {
299
+ finish(parseAt(path));
205
300
  return;
206
301
  }
207
302
  const timer = setTimeout(() => {
208
- finish('timeout');
303
+ finish({ status: 'timeout' });
209
304
  }, opts.waitMs);
210
305
  });
211
306
  }
212
307
  /**
213
- * Derive job state from meta.json, result.json, and the tail of log.jsonl.
214
- * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
308
+ * Derive job state from meta.json, the result file, and the tail of log.jsonl.
309
+ * If a pid is recorded, is not alive, and no result file exists → 'failed'.
215
310
  */
216
311
  export function jobStatus(jobId) {
217
312
  const meta = readMeta(jobId);
218
313
  const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
219
- // Derive effective state.
220
314
  let state = meta.status;
221
315
  if (state === 'live') {
222
- if (existsSync(resultPath(jobId))) {
223
- // result.json present but meta not yet updated (rare); trust the file.
316
+ const existing = existingResultPath(jobId);
317
+ if (existing !== null) {
318
+ // Result file present but meta not yet updated (rare); trust the file.
224
319
  try {
225
- const r = JSON.parse(readFileSync(resultPath(jobId), 'utf8'));
226
- state = r.status;
320
+ if (existing.endsWith('.md')) {
321
+ const { frontmatter } = parseMarkdownResult(readFileSync(existing, 'utf8'));
322
+ state = frontmatter.status;
323
+ }
324
+ else {
325
+ const r = JSON.parse(readFileSync(existing, 'utf8'));
326
+ state = r.status;
327
+ }
227
328
  }
228
329
  catch { /* leave as live */ }
229
330
  }
@@ -271,12 +372,20 @@ export function listJobs() {
271
372
  if (!existsSync(mp))
272
373
  continue;
273
374
  const meta = JSON.parse(readFileSync(mp, 'utf8'));
274
- // Derive effective state (result.json beats meta.status for live jobs).
375
+ // Derive effective state (result file beats meta.status for live jobs).
275
376
  let state = meta.status;
276
- if (state === 'live' && existsSync(join(dir, 'result.json'))) {
377
+ if (state === 'live') {
378
+ const mdP = join(dir, 'result.md');
379
+ const jsP = join(dir, 'result.json');
277
380
  try {
278
- const r = JSON.parse(readFileSync(join(dir, 'result.json'), 'utf8'));
279
- state = r.status;
381
+ if (existsSync(mdP)) {
382
+ const { frontmatter } = parseMarkdownResult(readFileSync(mdP, 'utf8'));
383
+ state = frontmatter.status;
384
+ }
385
+ else if (existsSync(jsP)) {
386
+ const r = JSON.parse(readFileSync(jsP, 'utf8'));
387
+ state = r.status;
388
+ }
280
389
  }
281
390
  catch { /* leave as live */ }
282
391
  }
@@ -22,8 +22,7 @@ export interface SkillResolutionOpts {
22
22
  export declare function resolveSkill(rawName: string, opts?: SkillResolutionOpts): Skill;
23
23
  export interface ParsedSkillQualifier {
24
24
  scope?: Scope;
25
- plugin?: string;
26
- name: string;
25
+ segments: string[];
27
26
  }
28
27
  export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
29
28
  export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];
@@ -5,6 +5,7 @@ import { listDirs, pathExists, readText, walkFiles, } from './fs-utils.js';
5
5
  import { readMarketplaceManifest, readPluginManifest } from './manifest.js';
6
6
  import { parseFrontmatter } from './frontmatter.js';
7
7
  import { ambiguous, notFound, usage } from './errors.js';
8
+ import { InputError } from './io.js';
8
9
  import { builtinSkillsRoot, marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.js';
9
10
  function getBuiltinPlugin() {
10
11
  const root = builtinSkillsRoot();
@@ -202,30 +203,46 @@ export function resolveSkill(rawName, opts = {}) {
202
203
  if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
203
204
  throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
204
205
  }
205
- if (parsed.plugin && opts.pluginFilter && parsed.plugin !== opts.pluginFilter) {
206
- throw usage(`plugin conflict: identifier "${rawName}" uses plugin "${parsed.plugin}" but --plugin is "${opts.pluginFilter}"`);
207
- }
208
206
  const effectiveScope = opts.scope ?? parsed.scope;
209
- const effectivePluginFilter = opts.pluginFilter ?? parsed.plugin;
210
- const direct = findSkillMatches(parsed.name, parsed.plugin, effectiveScope, effectivePluginFilter);
211
- if (direct.length > 0)
212
- return pickMatch(direct, parsed.name, parsed.plugin);
213
- // Fallback: bare `plugin/name` (no colon) — try splitting on first `/`.
214
- // Disambiguates "claude-authoring/rules" (which the search/list display also emits as
215
- // "user:claude-authoring/rules") from a nested scope-root skill of the same shape.
216
- if (!parsed.plugin && parsed.name.includes('/')) {
217
- const slashIdx = parsed.name.indexOf('/');
218
- const maybePlugin = parsed.name.slice(0, slashIdx);
219
- const rest = parsed.name.slice(slashIdx + 1);
220
- if (effectivePluginFilter === undefined || effectivePluginFilter === maybePlugin) {
221
- const fallback = findSkillMatches(rest, maybePlugin, effectiveScope, maybePlugin);
222
- if (fallback.length > 0)
223
- return pickMatch(fallback, rest, maybePlugin);
207
+ // Lookup-based disambiguation: if segments[0] matches an installed plugin, treat it as plugin.
208
+ // Otherwise the entire segments array is the skill path under the scope-direct plugin.
209
+ let pluginQualifier;
210
+ let skillName;
211
+ if (parsed.segments.length === 0) {
212
+ throw usage(`skill name required`);
213
+ }
214
+ if (opts.pluginFilter !== undefined) {
215
+ // Explicit plugin filter overrides disambiguation.
216
+ pluginQualifier = opts.pluginFilter;
217
+ skillName = parsed.segments.join('/');
218
+ }
219
+ else if (parsed.segments.length > 1) {
220
+ const maybePlugin = parsed.segments[0];
221
+ const pluginMatch = findPluginByName(maybePlugin, effectiveScope) ??
222
+ (effectiveScope === undefined ? null : findPluginByName(maybePlugin));
223
+ if (pluginMatch !== null) {
224
+ pluginQualifier = maybePlugin;
225
+ skillName = parsed.segments.slice(1).join('/');
226
+ }
227
+ else {
228
+ pluginQualifier = undefined;
229
+ skillName = parsed.segments.join('/');
224
230
  }
225
231
  }
226
- throw notFound(formatNotFoundMessage(rawName, parsed), {
227
- skill: parsed.name,
228
- plugin: parsed.plugin,
232
+ else {
233
+ pluginQualifier = undefined;
234
+ skillName = parsed.segments[0];
235
+ }
236
+ if (pluginQualifier && opts.pluginFilter && pluginQualifier !== opts.pluginFilter) {
237
+ throw usage(`plugin conflict: identifier "${rawName}" uses plugin "${pluginQualifier}" but --plugin is "${opts.pluginFilter}"`);
238
+ }
239
+ const effectivePluginFilter = opts.pluginFilter ?? pluginQualifier;
240
+ const direct = findSkillMatches(skillName, pluginQualifier, effectiveScope, effectivePluginFilter);
241
+ if (direct.length > 0)
242
+ return pickMatch(direct, skillName, pluginQualifier);
243
+ throw notFound(formatNotFoundMessage(rawName, skillName, pluginQualifier), {
244
+ skill: skillName,
245
+ plugin: pluginQualifier,
229
246
  scope: parsed.scope,
230
247
  });
231
248
  }
@@ -304,13 +321,13 @@ function pickMatch(matches, name, pluginQualifier) {
304
321
  })),
305
322
  });
306
323
  }
307
- function formatNotFoundMessage(rawName, parsed) {
308
- const suggestions = suggestSkills(parsed.name, parsed.plugin);
324
+ function formatNotFoundMessage(rawName, skillName, pluginQualifier) {
325
+ const suggestions = suggestSkills(skillName, pluginQualifier);
309
326
  const lines = [`skill not found: ${rawName}`];
310
- lines.push(' expected forms: <name>, <plugin>:<name>, <scope>:<plugin>/<name>');
327
+ lines.push(' expected forms: <name>, <plugin>/<name>, <scope>/<name>, <scope>/<plugin>/<name>');
311
328
  if (suggestions.length > 0) {
312
329
  const formatted = suggestions
313
- .map((s) => s.plugin === SCOPE_SKILL_PLUGIN ? s.name : `${s.plugin}:${s.name}`)
330
+ .map((s) => s.plugin === SCOPE_SKILL_PLUGIN ? s.name : `${s.plugin}/${s.name}`)
314
331
  .slice(0, 3);
315
332
  lines.push(` did you mean: ${formatted.join(', ')}`);
316
333
  }
@@ -386,29 +403,26 @@ function editDistance(a, b) {
386
403
  const SCOPE_QUALIFIERS = new Set(['user', 'project']);
387
404
  // Accepted identifier forms:
388
405
  // <name> — bare name; scope-root first, then plugins
389
- // <plugin>:<name> — explicit plugin
390
- // <scope>:<name> — scope-root in a specific scope
391
- // <scope>:<plugin>/<name> — fully qualified (matches `skill list` / `skill search` display)
392
- // Bare `<plugin>/<name>` (no colon) is handled as a fallback inside resolveSkill.
406
+ // <plugin>/<name> — explicit plugin (plugin may contain slashes)
407
+ // <scope>/<name> — scope-root in a specific scope
408
+ // <scope>/<plugin>/<name> — fully qualified; plugin-vs-path disambiguation is lookup-based in resolveSkill
393
409
  export function parseSkillQualifier(raw) {
394
- const colonIdx = raw.indexOf(':');
395
- if (colonIdx === -1)
396
- return { name: raw };
397
- const before = raw.slice(0, colonIdx);
398
- const after = raw.slice(colonIdx + 1);
399
- if (SCOPE_QUALIFIERS.has(before)) {
400
- const scope = before;
401
- const slashIdx = after.indexOf('/');
402
- if (slashIdx !== -1) {
403
- return {
404
- scope,
405
- plugin: after.slice(0, slashIdx),
406
- name: after.slice(slashIdx + 1),
407
- };
408
- }
409
- return { scope, name: after };
410
+ if (raw.includes(':')) {
411
+ const suggested = raw.replace(/:/g, '/');
412
+ throw new InputError({
413
+ error: 'invalid_qualifier',
414
+ message: "mixed separators ':' and '/' no longer supported; use slashes throughout",
415
+ received: raw,
416
+ field: 'name',
417
+ next: `Use ${suggested}.`,
418
+ });
419
+ }
420
+ const segments = raw.split('/');
421
+ if (SCOPE_QUALIFIERS.has(segments[0])) {
422
+ const scope = segments[0];
423
+ return { scope, segments: segments.slice(1) };
410
424
  }
411
- return { plugin: before, name: after };
425
+ return { segments };
412
426
  }
413
427
  function orderPluginsByResolution(plugins) {
414
428
  const score = (p) => {
@@ -10,6 +10,8 @@ export interface SpawnAgentOptions {
10
10
  };
11
11
  /** Max panes per tmux window before overflowing to a new window. */
12
12
  maxPanesPerWindow: number;
13
+ /** Display name passed to `claude -n`; surfaces in pane title and /resume picker. */
14
+ name?: string;
13
15
  }
14
16
  export interface SpawnAgentResult {
15
17
  status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
@@ -40,6 +42,9 @@ export interface DetachOptions {
40
42
  * uses the attached client's currently-focused pane — which drifts if the
41
43
  * user switches windows between kickoff and spawn. */
42
44
  targetPane?: string;
45
+ /** Display name passed to `claude -n`; ignored when `command` is set
46
+ * (caller controls the full argv in that mode). */
47
+ name?: string;
43
48
  }
44
49
  export interface DetachResult {
45
50
  status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
@@ -49,6 +54,19 @@ export interface DetachResult {
49
54
  export declare function isInTmux(): boolean;
50
55
  export declare function shellQuote(s: string): string;
51
56
  export declare function countPanesInCurrentWindow(): number;
57
+ /**
58
+ * Find a window in the current tmux session with fewer than `maxPanesPerWindow`
59
+ * panes AND where every existing pane has `claude` as a foreground process.
60
+ * Prefers the active window so the spawned pane is visible to the user;
61
+ * otherwise falls back to the first other eligible window. Returns the tmux
62
+ * window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
63
+ *
64
+ * Windows holding non-agent panes (dashboards, log tails, idle shells, editors,
65
+ * REPLs, etc.) are skipped so spawning never disrupts those workflows. A pane
66
+ * qualifies as long as `claude` is among its foreground commands — co-resident
67
+ * helpers like `caffeinate` don't disqualify it.
68
+ */
69
+ export declare function findWindowWithSpace(maxPanesPerWindow: number): string | null;
52
70
  /**
53
71
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
54
72
  * so the caller can return normally before the pane dies. No-op outside tmux
@@ -68,9 +86,14 @@ export declare function scheduleKillCurrentPane(delaySeconds: number): boolean;
68
86
  */
69
87
  export declare function spawnAndDetach(opts: DetachOptions): DetachResult;
70
88
  /**
71
- * Async sibling spawn. Launches a claude session in a new tmux pane or window
72
- * (depending on current pane count vs maxPanesPerWindow). Returns immediately
73
- * with the pane id; the parent stays alive.
89
+ * Async sibling spawn. Launches a claude session in a tmux pane, progressively
90
+ * filling existing windows up to `maxPanesPerWindow` before creating a new
91
+ * window. Returns immediately with the pane id; the parent stays alive.
92
+ *
93
+ * Placement order:
94
+ * 1. Current window, if it has space.
95
+ * 2. Any other window in the session with space.
96
+ * 3. New window (every existing window at capacity).
74
97
  *
75
98
  * If `fork` is set, uses `claude --resume <id> --fork-session`.
76
99
  */