spexcode 0.6.8 → 0.7.0-next.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.
Files changed (72) hide show
  1. package/node_modules/@spexcode/session-application/package.json +5 -5
  2. package/node_modules/@spexcode/session-events/package.json +2 -2
  3. package/node_modules/@spexcode/session-protocol/package.json +1 -1
  4. package/node_modules/@spexcode/session-runtime/package.json +2 -2
  5. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +12 -1
  6. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +62 -10
  7. package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
  8. package/node_modules/@spexcode/session-topology/package.json +2 -2
  9. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +127 -10
  10. package/node_modules/@spexcode/spec-cli/dist/cli.js +19 -0
  11. package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
  12. package/node_modules/@spexcode/spec-cli/dist/guide.js +9 -11
  13. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +11 -5
  14. package/node_modules/@spexcode/spec-cli/dist/harness.js +185 -28
  15. package/node_modules/@spexcode/spec-cli/dist/host.js +5 -1
  16. package/node_modules/@spexcode/spec-cli/dist/index.js +21 -65
  17. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
  18. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +45 -14
  19. package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
  20. package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
  21. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
  22. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
  23. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
  24. package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
  25. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +4 -0
  26. package/node_modules/@spexcode/spec-cli/dist/sessions.js +103 -35
  27. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
  28. package/node_modules/@spexcode/spec-cli/dist/supervise.js +1 -1
  29. package/node_modules/@spexcode/spec-cli/package.json +8 -6
  30. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +0 -1
  31. package/node_modules/@spexcode/spec-core/package.json +1 -1
  32. package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
  33. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  34. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  35. package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
  36. package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
  37. package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
  38. package/node_modules/@spexcode/transcript/dist/index.js +5 -0
  39. package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
  40. package/node_modules/@spexcode/transcript/dist/live.js +65 -0
  41. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
  42. package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
  43. package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
  44. package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
  45. package/node_modules/@spexcode/{spec-cli/dist/transcript-reader.d.ts → transcript/dist/turns.d.ts} +11 -5
  46. package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
  47. package/node_modules/@spexcode/transcript/package.json +30 -0
  48. package/node_modules/smol-toml/LICENSE +24 -0
  49. package/node_modules/smol-toml/README.md +418 -0
  50. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  51. package/node_modules/smol-toml/dist/date.js +127 -0
  52. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  53. package/node_modules/smol-toml/dist/error.js +63 -0
  54. package/node_modules/smol-toml/dist/extract.js +69 -0
  55. package/node_modules/smol-toml/dist/index.cjs +734 -0
  56. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  57. package/node_modules/smol-toml/dist/index.js +33 -0
  58. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  59. package/node_modules/smol-toml/dist/parse.js +149 -0
  60. package/node_modules/smol-toml/dist/primitive.js +238 -0
  61. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  62. package/node_modules/smol-toml/dist/stringify.js +181 -0
  63. package/node_modules/smol-toml/dist/struct.js +179 -0
  64. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  65. package/node_modules/smol-toml/dist/util.js +89 -0
  66. package/node_modules/smol-toml/package.json +68 -0
  67. package/package.json +3 -3
  68. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -27
  69. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
  70. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
  71. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
  72. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +0 -251
@@ -1,4 +1,5 @@
1
1
  import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
2
+ import { parse as parseToml } from 'smol-toml';
2
3
  import { join, dirname, basename } from 'node:path';
3
4
  import { homedir, tmpdir } from 'node:os';
4
5
  import { createHash, randomBytes } from 'node:crypto';
@@ -12,15 +13,14 @@ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
13
  import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
13
14
  import { codexHeadlessLaunchCommand } from './codex-headless.js';
14
15
  import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
15
- import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
16
+ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, interruptPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
16
17
  import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
17
18
  import { git } from '@spexcode/spec-core';
18
19
  import { shQuote } from './sh.js';
19
20
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
20
21
  import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
21
22
  import { writeFileIfChanged } from './file-write.js';
22
- import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace } from './execution-trace.js';
23
- import { readClaudeTranscript, readCodexTranscript, unsupportedTranscriptReader } from './transcript-reader.js';
23
+ import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
24
24
  import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
25
25
  // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
26
26
  // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
@@ -409,6 +409,83 @@ export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenEr
409
409
  return { ok: false, error: unprovenError };
410
410
  return coldWake();
411
411
  }
412
+ const RENDEZVOUS_INTERRUPT_WALL_MS = 10_000;
413
+ const RENDEZVOUS_INTERRUPT_SETTLE_MS = 15_000;
414
+ // @@@ interruptViaRendezvous - one `interrupt` line to the session's LIVE rendezvous listener, confirmed by the
415
+ // shim's own answer ([[shim-runtime]] runs the host's native abort — pi's ctx.abort(), opencode's
416
+ // session.abort — and writes interrupt-done / interrupt-rejected). Unlike a reply poke, whose durable copy is
417
+ // the timeline, an interrupt has nothing to fall back on: silence is a loud failure, never an optimistic ok.
418
+ // A generative shim serves its socket only while a turn process is alive, so a dead listener means no turn
419
+ // is running — nothing to interrupt — and an unproven probe sends nothing. `settle` additionally waits for
420
+ // that listener to go dead: for a one-turn-per-process adapter the abort ends the process, and confirming
421
+ // only then means a delivery that follows the interrupt wakes cold instead of poking an exiting agent.
422
+ export async function interruptViaRendezvous(id, harness, opts = {}) {
423
+ const probe = await rendezvousListening(id);
424
+ if (probe === 'dead')
425
+ return { ok: false, error: `no ${harness} turn is running for session ${id} - nothing to interrupt` };
426
+ if (probe === 'unproven')
427
+ return { ok: false, error: `${harness} rendezvous listener for session ${id} is unproven - interrupt NOT sent` };
428
+ const answered = await sendRendezvousInterrupt(id, harness);
429
+ if (!answered.ok || !opts.settle)
430
+ return answered;
431
+ const deadline = Date.now() + RENDEZVOUS_INTERRUPT_SETTLE_MS;
432
+ for (;;) {
433
+ const state = await rendezvousListening(id);
434
+ if (state === 'dead')
435
+ return { ok: true };
436
+ if (Date.now() >= deadline)
437
+ return { ok: false, error: `${harness} confirmed the interrupt but its turn process for session ${id} is still ${state === 'live' ? 'serving' : 'unproven'} after ${RENDEZVOUS_INTERRUPT_SETTLE_MS}ms` };
438
+ await new Promise((resolve) => setTimeout(resolve, 200));
439
+ }
440
+ }
441
+ function sendRendezvousInterrupt(id, harness) {
442
+ return new Promise((resolve) => {
443
+ let settled = false;
444
+ let c;
445
+ const done = (r) => {
446
+ if (settled)
447
+ return;
448
+ settled = true;
449
+ clearTimeout(wall);
450
+ try {
451
+ c?.destroy();
452
+ }
453
+ catch { /* */ }
454
+ resolve(r);
455
+ };
456
+ const wall = setTimeout(() => done({ ok: false, error: `${harness} did not confirm the interrupt for session ${id} within ${RENDEZVOUS_INTERRUPT_WALL_MS}ms (a shim materialized before interrupt support never answers - rerun spex materialize)` }), RENDEZVOUS_INTERRUPT_WALL_MS);
457
+ try {
458
+ c = createConnection({ path: rvSock(id) });
459
+ }
460
+ catch (e) {
461
+ done({ ok: false, error: `rendezvous socket connect threw: ${String(e)} - interrupt NOT sent` });
462
+ return;
463
+ }
464
+ c.on('error', (e) => done({ ok: false, error: `rendezvous socket error: ${e?.code || String(e)} - interrupt NOT confirmed` }));
465
+ c.on('close', () => done({ ok: false, error: `rendezvous connection closed before ${harness} answered the interrupt` }));
466
+ c.on('connect', () => c.write(`${JSON.stringify({ type: 'interrupt' })}\n`));
467
+ let buf = '';
468
+ c.on('data', (chunk) => {
469
+ buf += chunk.toString('utf8');
470
+ let nl;
471
+ while ((nl = buf.indexOf('\n')) >= 0) {
472
+ const line = buf.slice(0, nl);
473
+ buf = buf.slice(nl + 1);
474
+ let msg = {};
475
+ try {
476
+ msg = JSON.parse(line);
477
+ }
478
+ catch {
479
+ continue;
480
+ }
481
+ if (msg.type === 'interrupt-done')
482
+ return done({ ok: true });
483
+ if (msg.type === 'interrupt-rejected')
484
+ return done({ ok: false, error: `${harness} rejected the interrupt: ${msg.error || 'no reason given'}` });
485
+ }
486
+ });
487
+ });
488
+ }
412
489
  // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
413
490
  // 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
414
491
  // `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
@@ -592,8 +669,13 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
592
669
  `if [ "$1" = "--resume" ]; then`,
593
670
  ` tid=$2`,
594
671
  ...(attachTui ? [] : [
595
- // A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
596
- // a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
672
+ // A headless resume has no TUI to reload the thread. The shared app-server evicts an idle thread from its
673
+ // loaded set, and headless readiness proves online only for a RESIDENT thread, so resume must reopen it
674
+ // here — the load the visible TUI's `resume "$tid"` would have done — or readiness times out for a thread
675
+ // that is fine on disk. codex-generation-session above already ensured the app-server, so `$sock` is live.
676
+ ` ${SPEX} internal codex-reopen "$sock" "$tid" || exit 1`,
677
+ // A headless forced reopen with NO thread id and no prompt has nothing to attach and nothing to launch.
678
+ // Keep it a no-op instead of calling codex-launch without a prompt (which would mint an unrelated thread).
597
679
  `elif [ "$#" -eq 0 ]; then`,
598
680
  ` exit 0`,
599
681
  ]),
@@ -946,6 +1028,69 @@ const CODEX_MUTATION_CENSUS_MS = 15_000;
946
1028
  const CODEX_MUTATION_BASE_MS = 15_000;
947
1029
  const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000;
948
1030
  const codexArchiveBudgetMs = (bytes) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS);
1031
+ // @@@ codexReopenThread - load an evicted thread back into the shared app-server WITHOUT running a turn. The
1032
+ // app-server evicts an idle thread from its in-memory loaded set, and codex-headless readiness/liveness proves
1033
+ // online only when the thread is resident (thread/loaded/list). A visible-TUI resume reloads it implicitly via
1034
+ // `resume "$tid"`; headless has no TUI, so its resume must issue this `thread/resume` itself or readiness times
1035
+ // out for a thread that is perfectly fine on disk. `excludeTurns` + a one-row page keeps a huge rollout from
1036
+ // streaming back — we only need the load, not the history. Idempotent: reopening an already-loaded thread is a
1037
+ // no-op the server answers at once.
1038
+ export function codexReopenThread(sock, threadId, budgetMs = 20_000) {
1039
+ return new Promise((resolve) => {
1040
+ const conn = createConnection(sock);
1041
+ const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
1042
+ let upgraded = false, settled = false;
1043
+ const done = (r) => {
1044
+ if (settled)
1045
+ return;
1046
+ settled = true;
1047
+ clearTimeout(timer);
1048
+ try {
1049
+ conn.destroy();
1050
+ }
1051
+ catch { /* */ }
1052
+ resolve(r);
1053
+ };
1054
+ const timer = setTimeout(() => done({ ok: false, error: `codex app-server did not reopen thread ${threadId} within ${budgetMs}ms` }), budgetMs);
1055
+ conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
1056
+ conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/resume was answered' }));
1057
+ const send = (m) => conn.write(wsText(JSON.stringify(m)));
1058
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
1059
+ const handle = (json) => {
1060
+ let m;
1061
+ try {
1062
+ m = JSON.parse(json);
1063
+ }
1064
+ catch {
1065
+ return;
1066
+ }
1067
+ if (m.error)
1068
+ return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
1069
+ if (m.id === 1 && m.result) {
1070
+ send({ method: 'initialized', params: {} });
1071
+ return send({ id: 2, method: 'thread/resume', params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } } });
1072
+ }
1073
+ if (m.id === 2 && m.result)
1074
+ return done({ ok: true });
1075
+ };
1076
+ conn.on('data', (chunk) => {
1077
+ fs.buf = Buffer.concat([fs.buf, chunk]);
1078
+ if (!upgraded) {
1079
+ const i = fs.buf.indexOf('\r\n\r\n');
1080
+ if (i < 0)
1081
+ return;
1082
+ const head = fs.buf.slice(0, i).toString('utf8');
1083
+ if (!/^HTTP\/1\.1 101/.test(head))
1084
+ return done({ ok: false, error: 'codex app-server refused WebSocket upgrade for thread/resume' });
1085
+ upgraded = true;
1086
+ fs.buf = fs.buf.slice(i + 4);
1087
+ send(wsInitialize);
1088
+ }
1089
+ if (drainWsFrames(fs, conn, handle))
1090
+ done({ ok: false, error: 'codex app-server closed during thread/resume' });
1091
+ });
1092
+ });
1093
+ }
949
1094
  // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
950
1095
  // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
951
1096
  // supplies every protocol source kind explicitly for its thread/list calls.
@@ -2413,6 +2558,18 @@ function stripCodexTrustFor(cur, proj, hooksJson) {
2413
2558
  }
2414
2559
  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '');
2415
2560
  }
2561
+ // @@@ assertCodexConfigParses - ~/.codex/config.toml has TWO uncoordinated writers, codex itself and this adapter,
2562
+ // and neither rewrite is something the other waits for. A body read mid-write (a line cut short) or one already
2563
+ // broken must never be written back: codex refuses the whole file, and every dispatched thread then dies at
2564
+ // config load. The check is on the bytes about to land, so stripping this project's own duplicate still heals.
2565
+ function assertCodexConfigParses(file, body) {
2566
+ try {
2567
+ parseToml(body);
2568
+ }
2569
+ catch (error) {
2570
+ throw new Error(`refusing to rewrite ${file}: it does not parse as TOML (${error instanceof Error ? error.message : String(error)}); the file is broken or another writer is mid-write — repair it, then rerun`);
2571
+ }
2572
+ }
2416
2573
  // additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
2417
2574
  // `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
2418
2575
  // self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
@@ -2432,9 +2589,11 @@ export function writeCodexTrust(proj, events, cmdFor) {
2432
2589
  }
2433
2590
  const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`;
2434
2591
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson);
2592
+ const next = cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`;
2593
+ assertCodexConfigParses(file, next);
2435
2594
  if (!existsSync(home))
2436
2595
  mkdirSync(home, { recursive: true });
2437
- writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`);
2596
+ writeFileIfChanged(file, next);
2438
2597
  return file;
2439
2598
  }
2440
2599
  // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
@@ -2452,7 +2611,9 @@ function removeCodexTrust(proj) {
2452
2611
  !cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`))
2453
2612
  return;
2454
2613
  const cleaned = stripCodexTrustFor(cur, proj, hooksJson);
2455
- writeFileSync(file, cleaned ? `${cleaned}\n` : '');
2614
+ const next = cleaned ? `${cleaned}\n` : '';
2615
+ assertCodexConfigParses(file, next);
2616
+ writeFileIfChanged(file, next);
2456
2617
  }
2457
2618
  // is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
2458
2619
  function isTrackedFile(proj, f) {
@@ -2671,10 +2832,9 @@ export const claudeHarness = {
2671
2832
  dispatchId: 'claude',
2672
2833
  headless: false,
2673
2834
  events: CLAUDE_EVENTS,
2674
- ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2835
+ ownsRendezvous: true, // claude's background daemon opens the rendezvous control socket (prompt delivery + liveness)
2675
2836
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2676
- executionTrace: readProjectJsonlExecutionTrace,
2677
- readTranscript: readClaudeTranscript,
2837
+ transcript: claudeTranscript,
2678
2838
  launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2679
2839
  baseCmd: claudeBaseCmd,
2680
2840
  oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
@@ -2792,10 +2952,9 @@ export const codexHarness = {
2792
2952
  headless: false,
2793
2953
  sharedRuntimeSpawn: true,
2794
2954
  events: CODEX_EVENTS,
2795
- ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2955
+ ownsRendezvous: false, // no rendezvous daemon — liveness + prompts through the project app-server socket
2796
2956
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
2797
- executionTrace: readCodexExecutionTrace,
2798
- readTranscript: readCodexTranscript,
2957
+ transcript: codexTranscript,
2799
2958
  launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
2800
2959
  baseCmd: codexBaseCmd,
2801
2960
  oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
@@ -3094,11 +3253,13 @@ async function codexHeadlessReadinessProof(current) {
3094
3253
  return null;
3095
3254
  const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
3096
3255
  .find((candidate) => candidate.key === codexDescriptorKey(endpoint));
3097
- if (!descriptor?.residency)
3256
+ if (!descriptor)
3098
3257
  return null;
3099
3258
  const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
3100
3259
  if (!generationBefore)
3101
3260
  return null;
3261
+ if (!descriptor.residency)
3262
+ return null;
3102
3263
  let resident;
3103
3264
  try {
3104
3265
  resident = await descriptor.residency();
@@ -3179,7 +3340,7 @@ export const codexHeadlessHarness = {
3179
3340
  // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
3180
3341
  // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
3181
3342
  // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
3182
- // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
3343
+ // CLAUDE_BG_RENDEZVOUS_SOCK) speaking claude's rendezvous line protocol — so
3183
3344
  // deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
3184
3345
  // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
3185
3346
  // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
@@ -3189,10 +3350,9 @@ export const piHarness = {
3189
3350
  dispatchId: 'pi',
3190
3351
  headless: false,
3191
3352
  events: PI_EVENTS,
3192
- ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
3353
+ ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks claude's rendezvous protocol
3193
3354
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
3194
- executionTrace: readSessionJsonlExecutionTrace,
3195
- readTranscript: (threadId, range) => unsupportedTranscriptReader('pi', threadId, range),
3355
+ transcript: piTranscript,
3196
3356
  launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
3197
3357
  baseCmd: piBaseCmd,
3198
3358
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
@@ -3242,6 +3402,8 @@ export const piHeadlessHarness = {
3242
3402
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
3243
3403
  liveness: sessionHomeLiveness,
3244
3404
  deliver: deliverViaPiHeadless,
3405
+ // the controller aborts its own turn child natively and confirms only once that child is gone
3406
+ interrupt: interruptPiHeadless,
3245
3407
  cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
3246
3408
  coldRuntime: async (rec) => {
3247
3409
  const result = await piHeadlessColdRuntime(rec);
@@ -3262,8 +3424,7 @@ export const zcodeHarness = {
3262
3424
  events: ZCODE_EVENTS,
3263
3425
  ownsRendezvous: false,
3264
3426
  paneTitleIsSelfSummary: false,
3265
- executionTrace: noExecutionTrace,
3266
- readTranscript: (threadId, range) => unsupportedTranscriptReader('zcode', threadId, range),
3427
+ transcript: unsupportedTranscript('zcode'),
3267
3428
  launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
3268
3429
  baseCmd: zcodeBaseCmd,
3269
3430
  // z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
@@ -3299,8 +3460,7 @@ export const opencodeHarness = {
3299
3460
  // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
3300
3461
  ownsRendezvous: true,
3301
3462
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
3302
- executionTrace: readLocalStoreExecutionTrace,
3303
- readTranscript: (threadId, range) => unsupportedTranscriptReader('opencode', threadId, range),
3463
+ transcript: opencodeTranscript,
3304
3464
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
3305
3465
  baseCmd: opencodeBaseCmd,
3306
3466
  // `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
@@ -3361,6 +3521,9 @@ export const opencodeHeadlessHarness = {
3361
3521
  deliver: async (rec, text) => {
3362
3522
  return deliverViaSocketOrWake(rec.session, text, rec.mid, () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)), `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`);
3363
3523
  },
3524
+ // `opencode run` serves the rendezvous socket only for the turn it runs, so the plugin's session abort IS the
3525
+ // interrupt, and the listener going dead is the turn's exit — confirmed only then, so the next delivery wakes cold
3526
+ interrupt: (rec) => interruptViaRendezvous(rec.session, 'opencode-headless', { settle: true }),
3364
3527
  };
3365
3528
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
3366
3529
  export const HARNESSES = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness];
@@ -3390,12 +3553,6 @@ export function launcherList(root = mainCheckout()) {
3390
3553
  })
3391
3554
  .sort((a, b) => a.name.localeCompare(b.name));
3392
3555
  }
3393
- // The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
3394
- // it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
3395
- export function dashboardLauncherList(root = mainCheckout()) {
3396
- const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true;
3397
- return launcherList(root).filter((launcher) => showHeadless || !launcher.headless);
3398
- }
3399
3556
  export const MISSING_DEFAULT_LAUNCHER_ERROR = 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})';
3400
3557
  // the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
3401
3558
  // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
@@ -506,7 +506,11 @@ function readBody(req) {
506
506
  export function startHostDashboard(opts) {
507
507
  const distDir = opts.distDir ?? resolveDistDir();
508
508
  const peers = new MachinePeerGateway();
509
- peers.start();
509
+ // a refused control socket is reclaimed inside start(); only a LIVE owner rejects, and that is fatal for a host
510
+ void peers.start().catch((error) => {
511
+ console.error(`spex dashboard: ${error instanceof Error ? error.message : String(error)}`);
512
+ process.exit(1);
513
+ });
510
514
  const sseClients = new Set();
511
515
  let lastBroadcast = '';
512
516
  // reconcile → push the fresh list to every /projects/stream subscriber when it changed.
@@ -7,7 +7,7 @@ import { Hono } from 'hono';
7
7
  import { cors } from 'hono/cors';
8
8
  import { etag } from 'hono/etag';
9
9
  import { createNodeWebSocket } from '@hono/node-ws';
10
- import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig, readAliasedRawRecord, runtimeRoot } from '@spexcode/spec-core';
10
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig, runtimeRoot } from '@spexcode/spec-core';
11
11
  import { issuesEnabled, resolveRemark, retractRemark } from './localIssues.js';
12
12
  import { closeIssue, createIssue, findIssue, mergedIssues, promote } from './issues.js';
13
13
  import { remarkWithLoopIn, replyIssueWithLoopIn } from './loop-in.js';
@@ -21,10 +21,9 @@ import { gitA, gitTry, repoRoot } from '@spexcode/spec-core';
21
21
  import { cockpitReview } from './cockpit.js';
22
22
  import { EMPTY_PROMPT_ERROR, retractDiffComment, listSessions, listArchivedSessionIndex, sendText, drainSession, markHumanPromptActive, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, linkZCodeChildSession, projectCreatedSession, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, startWorktreeTrashReaper, SessionRecordUnusable, TMUX_SOCK, sessionDiff, saveDiffComment, sendDiffComments, canonicalWatchRecipients } from './sessions.js';
23
23
  import { readTimeline } from './session-timeline.js';
24
- import { readSessionExecution, sessionExecutionStream } from './session-execution.js';
25
- import { defaultHarness, HARNESSES, codexHarness, dashboardLauncherList, launcherDefault, harnessById } from './harness.js';
24
+ import { readSessionTranscript, readSessionTranscriptTool, sessionTranscriptStream } from './session-transcript.js';
25
+ import { defaultHarness, HARNESSES, codexHarness, launcherList, launcherDefault } from './harness.js';
26
26
  import { ensureCodexGenerationLedger, reclaimDrainingCodexGenerations } from './codex-runtime-generations.js';
27
- import { TranscriptReadError } from './transcript-reader.js';
28
27
  import { readBlobByHash } from '@spexcode/spec-eval/evaltab';
29
28
  import { putBlob } from '@spexcode/spec-eval/cache';
30
29
  import { fileHumanReading } from '@spexcode/spec-eval/filing';
@@ -299,11 +298,10 @@ app.post('/api/evidence', async (c) => {
299
298
  });
300
299
  // the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
301
300
  // `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
302
- // `.layout.main`) and the dashboard-visible launcher profiles ([[launcher-visibility]]) the New-Session picker
303
- // offers — `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
301
+ // `.layout.main`) and the complete launcher profiles ([[launcher-select]]) the New-Session picker offers —
302
+ // `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
304
303
  // behind the gateway auth; the browser can read but never edit config) — plus the configured `default` NAME
305
- // so the picker pre-selects the SAME launcher a bare `spex session new` uses when that row is visible, else
306
- // its first visible row rather than a hidden headless default,
304
+ // so the picker pre-selects the SAME launcher a bare `spex session new` uses, else its first row.
307
305
  // Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
308
306
  // built-in `claude` launcher.
309
307
  // `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
@@ -311,7 +309,7 @@ app.post('/api/evidence', async (c) => {
311
309
  // beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
312
310
  app.get('/api/settings', async (c) => c.json({
313
311
  layout: await resolveLayout(),
314
- launchers: dashboardLauncherList(),
312
+ launchers: launcherList(),
315
313
  tmuxSocket: TMUX_SOCK,
316
314
  ...launcherDefault(),
317
315
  }));
@@ -784,60 +782,12 @@ app.get('/api/sessions/:id/capture', async (c) => {
784
782
  return c.text('session offline (no live pane)', 409);
785
783
  return c.text('capture failed', 502);
786
784
  });
787
- // A live adapter-owned execution observation, intentionally distinct from the durable conversation timeline.
788
- // The response carries only the backend-normalized latest working note and typed tool rows; no transcript bytes
789
- // or parser schema cross this API boundary.
790
- app.get('/api/sessions/:id/execution', (c) => {
791
- const execution = readSessionExecution(c.req.param('id') || '');
792
- return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404);
793
- });
794
- app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c));
795
- // The native transcript is a bounded payload behind the durable timeline index. Bounds are explicit epoch
796
- // milliseconds so the route never guesses which status interval the caller intended.
797
- app.get('/api/sessions/:id/transcript', async (c) => {
798
- const id = c.req.param('id') || '';
799
- const fromRaw = c.req.query('from');
800
- const toRaw = c.req.query('to');
801
- if (fromRaw == null || toRaw == null || fromRaw === '' || toRaw === '')
802
- return c.json({ error: 'transcript needs both from and to epoch milliseconds' }, 400);
803
- const from = Number(fromRaw);
804
- const to = Number(toRaw);
805
- if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isInteger(from) || !Number.isInteger(to) || from >= to)
806
- return c.json({ error: 'transcript interval is invalid: from and to must be integer epoch milliseconds with from < to' }, 400);
807
- let raw;
808
- try {
809
- raw = readAliasedRawRecord(id);
810
- }
811
- catch (error) {
812
- return c.json({ error: `session ${id} record is unreadable: ${error instanceof Error ? error.message : String(error)}` }, 500);
813
- }
814
- if (!raw || !raw.governed)
815
- return c.json({ error: `session ${id} does not exist` }, 404);
816
- let harness;
817
- try {
818
- harness = harnessById(typeof raw.harness === 'string' && raw.harness ? raw.harness : defaultHarness.id);
819
- }
820
- catch (error) {
821
- return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
822
- }
823
- const threadId = harness.exactNativeTargetId({
824
- session: raw.session_id,
825
- harnessSessionId: typeof raw.harness_session_id === 'string' ? raw.harness_session_id : null,
826
- stopped: !!raw.stopped,
827
- archived: !!raw.archived,
828
- });
829
- if (!threadId)
830
- return c.json({ error: `session ${id} transcript is unavailable: native harness identity is missing` }, 409);
831
- try {
832
- return c.json(await harness.readTranscript(threadId, { from, to }));
833
- }
834
- catch (error) {
835
- if (!(error instanceof TranscriptReadError))
836
- throw error;
837
- const status = error.reason === 'unsupported' ? 501 : error.reason === 'invalid' ? 422 : 409;
838
- return c.json({ error: error.message, reason: error.reason }, status);
839
- }
840
- });
785
+ // The native transcript behind the durable timeline index ([[transcript-reader]], [[session-transcript]]):
786
+ // a closed interval by GET, the open working interval by SSE. Bounds are explicit epoch milliseconds so the
787
+ // route never guesses which stretch the caller intended; native bytes stay behind the adapter.
788
+ app.get('/api/sessions/:id/transcript', (c) => readSessionTranscript(c));
789
+ app.get('/api/sessions/:id/transcript/stream', (c) => sessionTranscriptStream(c));
790
+ app.get('/api/sessions/:id/transcript/tool/:toolId', (c) => readSessionTranscriptTool(c));
841
791
  // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
842
792
  // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
843
793
  // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
@@ -1045,8 +995,14 @@ app.post('/api/sessions/:id/input', async (c) => {
1045
995
  return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
1046
996
  const deliveryKey = typeof body?.deliveryId === 'string' && body.deliveryId.trim() ? body.deliveryId.trim() : undefined;
1047
997
  // Command Box acceptance is the durable append. Do not hold the HTTP request on a
1048
- // slow native handoff: the delivery supervisor already owns the queued retry path.
1049
- const r = await sendText(id, text, undefined, deliveryKey ? { deliveryKey, deferDrain: true } : { deferDrain: true });
998
+ // slow native handoff: the delivery supervisor already owns the queued retry path. The Conversation
999
+ // footer is a Command Box on a terminal-free surface, so it says `replyVia:"note"` here exactly as a
1000
+ // text send would, and the note-reply insert rides the same delivery.
1001
+ const r = await sendText(id, text, undefined, {
1002
+ deferDrain: true,
1003
+ ...(deliveryKey ? { deliveryKey } : {}),
1004
+ ...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
1005
+ });
1050
1006
  if (!r.ok)
1051
1007
  return c.json(r, 502);
1052
1008
  // Start the first handoff without holding the HTTP response on native readiness. The queue remains the
@@ -51,8 +51,9 @@ export declare class MachinePeerGateway {
51
51
  private control;
52
52
  private retry;
53
53
  private closing;
54
- start(): void;
54
+ start(): Promise<void>;
55
55
  private startControl;
56
+ private listenControl;
56
57
  private handleRpc;
57
58
  private startInbound;
58
59
  private stopInbound;
@@ -72,13 +72,17 @@ export function readPeerMachineId() {
72
72
  writeStore({ ...store, machineId });
73
73
  return machineId;
74
74
  }
75
+ // @@@ peer sender spelling - the authenticated peer identity travels as the message's ordinary `senderSessionId`,
76
+ // so it must fit the protocol's frozen session_id grammar `[0-9A-Za-z_][0-9A-Za-z_-]*` (session-protocol §5.2:
77
+ // no `:`, no leading `-`; namespaces are encoded INTO the id). `peer_<machineId>_<sessionId>` is that encoding;
78
+ // the earlier `peer:` spelling was refused by every receiving backend with PROTOCOL_SESSION_ID_INVALID.
75
79
  export function peerSenderRef(machineId, sessionId) {
76
- return sessionId && validMachineId(sessionId) ? `peer:${machineId}:${sessionId}` : `peer:${machineId}`;
80
+ return sessionId && validMachineId(sessionId) ? `peer_${machineId}_${sessionId}` : `peer_${machineId}`;
77
81
  }
78
82
  function validPeerSender(value, machineId) {
79
83
  if (typeof value !== 'string')
80
84
  return false;
81
- const match = value.match(/^peer:([0-9a-f-]{36})(?::([0-9a-f-]{36}))?$/i);
85
+ const match = value.match(/^peer_([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?$/i);
82
86
  return !!match && match[1] === machineId && (match[2] === undefined || validMachineId(match[2]));
83
87
  }
84
88
  export function findMachinePeer(sshAddress) {
@@ -367,34 +371,61 @@ function sshArgs(peer) {
367
371
  '--', peer.sshAddress,
368
372
  ];
369
373
  }
374
+ // true iff something ACCEPTS a connection at this unix path; any error (ECONNREFUSED for a leftover path, ENOENT
375
+ // for a vanished one) means no live listener.
376
+ function probeUnixListener(path) {
377
+ return new Promise((resolve) => {
378
+ const socket = createConnection(path);
379
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
380
+ socket.once('error', () => resolve(false));
381
+ });
382
+ }
370
383
  export class MachinePeerGateway {
371
384
  inbound = new Map();
372
385
  children = new Map();
373
386
  control = null;
374
387
  retry = null;
375
388
  closing = false;
389
+ // start() resolves once the control socket is claimed. With no leftover path the claim is synchronous (the
390
+ // listener is issued before the promise settles, so callers may issue control RPCs right away); a leftover path
391
+ // is probed first, which is the only asynchronous branch.
376
392
  start() {
377
393
  for (const peer of listMachinePeers())
378
394
  this.startInbound(peer);
379
- this.startControl();
380
- for (const peer of listMachinePeers())
381
- if (peer.owner)
382
- this.ensureTunnel(peer);
383
- this.retry = setInterval(() => {
395
+ return this.startControl().then(() => {
384
396
  for (const peer of listMachinePeers())
385
- if (peer.owner && peer.state === 'connecting')
397
+ if (peer.owner)
386
398
  this.ensureTunnel(peer);
387
- }, PEER_RETRY_MS);
388
- this.retry.unref();
399
+ this.retry = setInterval(() => {
400
+ for (const peer of listMachinePeers())
401
+ if (peer.owner && peer.state === 'connecting')
402
+ this.ensureTunnel(peer);
403
+ }, PEER_RETRY_MS);
404
+ this.retry.unref();
405
+ });
389
406
  }
407
+ // @@@ stale control socket - a killed gateway never unlinks peer.sock, so the FILE proves nothing about
408
+ // ownership; only a connect does. A live gateway accepts the connection; a leftover path refuses it
409
+ // (ECONNREFUSED). So a missing path is claimed at once, a refusing path is unlinked and reclaimed, and only an
410
+ // accepting listener is "another `spex dashboard`" — the same test claude-rendezvous applies to its socket file.
390
411
  startControl() {
391
412
  const path = peerSocketPath();
392
413
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
393
- if (existsSync(path)) {
394
- const error = new Error(`machine peer service already owns ${path} — another \`spex dashboard\` may be running`);
395
- error.name = 'BackendError';
396
- throw error;
414
+ if (!existsSync(path)) {
415
+ this.listenControl(path);
416
+ return Promise.resolve();
397
417
  }
418
+ return probeUnixListener(path).then((live) => {
419
+ if (live) {
420
+ const error = new Error(`machine peer service already owns ${path} — another \`spex dashboard\` is running`);
421
+ error.name = 'BackendError';
422
+ throw error;
423
+ }
424
+ rmSync(path, { force: true });
425
+ this.listenControl(path);
426
+ });
427
+ }
428
+ listenControl(path) {
398
429
  this.control = createNetServer({ allowHalfOpen: true }, (socket) => {
399
430
  let input = '';
400
431
  socket.setEncoding('utf8');
@@ -89,8 +89,16 @@ export const SpexcodePlugin = async (ctx) => {
89
89
  }
90
90
 
91
91
  // The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
92
- // timeline line for its next turn boundary; the SDK injection remains host-owned.
93
- rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
92
+ // timeline line for its next turn boundary; the SDK injection remains host-owned. An interrupt is
93
+ // opencode's own session abort on the adopted root session, confirmed back over the same socket.
94
+ rt.serveRendezvous(injectPrompt, {
95
+ canInject: () => !!(client && rootSession),
96
+ interrupt: async () => {
97
+ if (!client || !rootSession) throw new Error("no opencode session is adopted - nothing to interrupt")
98
+ const r = await client.session.abort({ path: { id: rootSession } })
99
+ if (r === false || (r && r.data === false)) throw new Error("no opencode turn is running - nothing to interrupt")
100
+ },
101
+ })
94
102
 
95
103
  const toolPayload = (input, output) => {
96
104
  const sid = (input && input.sessionID) || ""