spexcode 0.6.8 → 0.7.0-next.1

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 +104 -36
  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 +46 -0
  36. package/node_modules/@spexcode/transcript/dist/frames.js +87 -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 +50 -0
  42. package/node_modules/@spexcode/transcript/dist/parsers.js +395 -0
  43. package/node_modules/@spexcode/transcript/dist/readers.d.ts +16 -0
  44. package/node_modules/@spexcode/transcript/dist/readers.js +375 -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-application",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Adopter-owned composition of session protocol and topology transactions.",
@@ -16,10 +16,10 @@
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
18
  "dependencies": {
19
- "@spexcode/session-events": "0.6.8",
20
- "@spexcode/session-protocol": "0.6.8",
21
- "@spexcode/session-runtime": "0.6.8",
22
- "@spexcode/session-topology": "0.6.8"
19
+ "@spexcode/session-events": "0.7.0-next.1",
20
+ "@spexcode/session-protocol": "0.7.0-next.1",
21
+ "@spexcode/session-runtime": "0.7.0-next.1",
22
+ "@spexcode/session-topology": "0.7.0-next.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-events",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Append-only session facts and deterministic ordered replay inputs.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.6.8" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.1" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-protocol",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "type": "module",
5
5
  "description": "A durable SQLite session message protocol.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-runtime",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Adopter-owned bindings between protocol addresses and native runtime identities.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.6.8" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.1" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -10,11 +10,22 @@ export interface FilesystemClassification {
10
10
  export declare function classifyFilesystemType(type: number | bigint): FilesystemClassification;
11
11
  export interface LocalityDetector {
12
12
  readonly platform: string;
13
- statfsType(parentPath: string): number | bigint;
13
+ readonly classify?: (parentPath: string) => FilesystemClassification;
14
14
  }
15
+ export declare const linuxLocalityDetector: (statfsType: (parentPath: string) => number | bigint) => LocalityDetector;
16
+ interface DarwinMountEntry {
17
+ readonly mountPoint: string;
18
+ readonly fstype: string;
19
+ readonly flags: ReadonlySet<string>;
20
+ }
21
+ export declare function parseDarwinMountTable(output: string): DarwinMountEntry[];
22
+ export declare function classifyDarwinMount(mountTable: string, resolvedPath: string): FilesystemClassification;
23
+ export declare const darwinLocalityDetector: (readMountTable: () => string) => LocalityDetector;
15
24
  export declare function requireLocalDatabasePathWithDetector(databasePath: string, options: {
16
25
  assumeLocal?: boolean;
17
26
  }, detector: LocalityDetector): string;
27
+ export declare function localityDetectorForPlatform(platform: string): LocalityDetector;
18
28
  export declare function requireLocalDatabasePath(databasePath: string, options?: {
19
29
  assumeLocal?: boolean;
20
30
  }): string;
31
+ export {};
@@ -1,4 +1,5 @@
1
- import { statfsSync } from 'node:fs';
1
+ import { execFileSync } from 'node:child_process';
2
+ import { realpathSync, statSync, statfsSync } from 'node:fs';
2
3
  import { isAbsolute, dirname } from 'node:path';
3
4
  import { DatabasePathError } from './path.js';
4
5
  export class LocalityError extends Error {
@@ -44,6 +45,54 @@ export function classifyFilesystemType(type) {
44
45
  return { locality: 'local', name: local.name };
45
46
  return { locality: 'undetermined', name: `0x${magic.toString(16)}` };
46
47
  }
48
+ export const linuxLocalityDetector = (statfsType) => ({
49
+ platform: 'linux',
50
+ classify: parentPath => classifyFilesystemType(statfsType(parentPath)),
51
+ });
52
+ // Darwin filesystem type names that are network transports even when the mount table omits MNT_LOCAL. `local`
53
+ // wins when present (it is the kernel's own verdict); this list only names the refusal, so the operator reads
54
+ // "smbfs" instead of a bare "undetermined".
55
+ const DARWIN_NETWORK_FILESYSTEM_NAMES = new Set(['nfs', 'smbfs', 'afpfs', 'webdav', 'cifs', 'ftp']);
56
+ // `/sbin/mount` prints one `<device> on <mount point> (<fstype>, <flag>, …)` line per mount. The mount point may
57
+ // contain spaces (`/Volumes/My Disk`); the parenthesised list is always last and never contains `)`.
58
+ const DARWIN_MOUNT_LINE = /^.+ on (.+) \(([^)]*)\)$/;
59
+ export function parseDarwinMountTable(output) {
60
+ const entries = [];
61
+ for (const line of output.split('\n')) {
62
+ const match = DARWIN_MOUNT_LINE.exec(line.trim());
63
+ if (!match)
64
+ continue;
65
+ const [fstype = '', ...flags] = match[2].split(',').map(part => part.trim()).filter(Boolean);
66
+ entries.push({ mountPoint: match[1], fstype, flags: new Set(flags) });
67
+ }
68
+ return entries;
69
+ }
70
+ const mountPointCovers = (mountPoint, path) => mountPoint === '/' || path === mountPoint || path.startsWith(mountPoint.endsWith('/') ? mountPoint : `${mountPoint}/`);
71
+ export function classifyDarwinMount(mountTable, resolvedPath) {
72
+ let entry;
73
+ for (const candidate of parseDarwinMountTable(mountTable)) {
74
+ if (!mountPointCovers(candidate.mountPoint, resolvedPath))
75
+ continue;
76
+ if (!entry || candidate.mountPoint.length > entry.mountPoint.length)
77
+ entry = candidate;
78
+ }
79
+ if (!entry)
80
+ return { locality: 'undetermined', name: 'no mount table entry' };
81
+ if (entry.flags.has('local'))
82
+ return { locality: 'local', name: entry.fstype };
83
+ if (DARWIN_NETWORK_FILESYSTEM_NAMES.has(entry.fstype))
84
+ return { locality: 'network', name: entry.fstype };
85
+ return { locality: 'undetermined', name: entry.fstype };
86
+ }
87
+ export const darwinLocalityDetector = (readMountTable) => ({
88
+ platform: 'darwin',
89
+ classify: parentPath => {
90
+ // `mount` never fails for a missing directory, so the parent's absence must be raised here for the resolver
91
+ // to keep its actionable PROTOCOL_PATH_PARENT_MISSING error.
92
+ statSync(parentPath);
93
+ return classifyDarwinMount(readMountTable(), realpathSync(parentPath));
94
+ },
95
+ });
47
96
  export function requireLocalDatabasePathWithDetector(databasePath, options, detector) {
48
97
  if (!isAbsolute(databasePath)) {
49
98
  throw new DatabasePathError('PROTOCOL_PATH_NOT_ABSOLUTE', 'databasePath must be absolute before locality detection');
@@ -51,12 +100,12 @@ export function requireLocalDatabasePathWithDetector(databasePath, options, dete
51
100
  if (options.assumeLocal)
52
101
  return databasePath;
53
102
  const parent = dirname(databasePath);
54
- if (detector.platform !== 'linux') {
103
+ if (!detector.classify) {
55
104
  throw new LocalityError('LOCALITY_DETECTOR_UNAVAILABLE', `no filesystem locality detector for platform ${detector.platform}; pass --assume-local-storage only after auditing ${parent}`);
56
105
  }
57
- let type;
106
+ let classification;
58
107
  try {
59
- type = detector.statfsType(parent);
108
+ classification = detector.classify(parent);
60
109
  }
61
110
  catch (error) {
62
111
  // @@@missing-parent - Preserve the actionable path error without pretending locality was established.
@@ -65,7 +114,6 @@ export function requireLocalDatabasePathWithDetector(databasePath, options, dete
65
114
  }
66
115
  throw new LocalityError('LOCALITY_PROBE_FAILED', `could not determine the filesystem of ${parent}`, error);
67
116
  }
68
- const classification = classifyFilesystemType(type);
69
117
  if (classification.locality === 'network') {
70
118
  throw new LocalityError('LOCALITY_NETWORK_FILESYSTEM', `${parent} is ${classification.name}; advisory locking is not admitted there`);
71
119
  }
@@ -74,10 +122,14 @@ export function requireLocalDatabasePathWithDetector(databasePath, options, dete
74
122
  }
75
123
  return databasePath;
76
124
  }
77
- const linuxDetector = {
78
- platform: process.platform,
79
- statfsType: parentPath => statfsSync(parentPath).type,
80
- };
125
+ const readDarwinMountTable = () => execFileSync('/sbin/mount', [], { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
126
+ export function localityDetectorForPlatform(platform) {
127
+ if (platform === 'linux')
128
+ return linuxLocalityDetector(parentPath => statfsSync(parentPath).type);
129
+ if (platform === 'darwin')
130
+ return darwinLocalityDetector(readDarwinMountTable);
131
+ return { platform };
132
+ }
81
133
  export function requireLocalDatabasePath(databasePath, options = {}) {
82
- return requireLocalDatabasePathWithDetector(databasePath, options, linuxDetector);
134
+ return requireLocalDatabasePathWithDetector(databasePath, options, localityDetectorForPlatform(process.platform));
83
135
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-selflaunch",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "A fail-closed self-launch adopter for the SpexCode session protocol.",
@@ -24,8 +24,8 @@
24
24
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
25
25
  },
26
26
  "dependencies": {
27
- "@spexcode/session-protocol": "0.6.8",
28
- "@spexcode/session-runtime": "0.6.8"
27
+ "@spexcode/session-protocol": "0.7.0-next.1",
28
+ "@spexcode/session-runtime": "0.7.0-next.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-topology",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "A neutral SQLite relation model for session addresses.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.6.8" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.1" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -2,7 +2,8 @@
2
2
  // @@@ spex launcher ([[release-launcher]]) - package installs execute the package's compiled CLI directly;
3
3
  // tsx remains a development tool and never enters an adopter's runtime closure.
4
4
  import { spawn, spawnSync } from 'node:child_process'
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
5
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { dirname, join } from 'node:path'
8
9
 
@@ -16,7 +17,19 @@ const workspace = join(pkg, '..')
16
17
  // carry unresolved conflict markers, so hooks keep the retryable exit-75 contract during a merge.
17
18
  const sourceRoot = join(pkg, 'src')
18
19
  if (existsSync(sourceRoot)) {
19
- const srcRoots = [sourceRoot, join(pkg, '..', 'packages', 'spec-core', 'src'), join(pkg, '..', 'spec-eval', 'src'), join(pkg, '..', 'spec-forge', 'src')]
20
+ const buildTargets = [
21
+ ['packages/session-protocol', 'dist/index.js'],
22
+ ['packages/session-topology', 'dist/index.js'],
23
+ ['packages/session-runtime', 'dist/index.js'],
24
+ ['packages/session-events', 'dist/index.js'],
25
+ ['packages/session-application', 'dist/index.js'],
26
+ ['packages/session-selflaunch', 'dist/index.js'],
27
+ ['packages/spec-core', 'dist/index.js'],
28
+ ['spec-eval', 'dist/index.js'],
29
+ ['spec-forge', 'dist/index.js'],
30
+ ['spec-cli', 'dist/cli.js'],
31
+ ]
32
+ const srcRoots = buildTargets.map(([packagePath]) => join(pkg, '..', packagePath, 'src'))
20
33
  const conflicted = srcRoots.flatMap((root) => {
21
34
  if (!existsSync(root)) return []
22
35
  return readdirSync(root, { recursive: true })
@@ -34,8 +47,10 @@ if (existsSync(sourceRoot)) {
34
47
  }
35
48
 
36
49
  // @@@ source-workspace build ([[release-launcher]]) - the git hooks invoke this launcher straight from a
37
- // checkout, where dist is intentionally untracked. Rebuild the complete runtime closure before linting
38
- // candidate source; an installed package has no src tree and stays a pure Node -> dist execution path.
50
+ // checkout, where dist is intentionally untracked. Import main's fresh closure and rebuild only changed
51
+ // packages plus dependents before linting candidate source; an untrusted baseline falls back to the complete
52
+ // ordered driver. Every package still publishes its dist atomically. An installed package has no src tree
53
+ // and stays a pure Node -> dist execution path.
39
54
  const runtimeEntries = [
40
55
  cli,
41
56
  join(workspace, 'packages', 'spec-core', 'dist', 'index.js'),
@@ -49,8 +64,9 @@ if (existsSync(sourceRoot)) {
49
64
  && !/\.d\.ts$/.test(path)
50
65
  && !/(^|[./])[^/]+\.test\.(ts|tsx|js|mjs)$/.test(path)
51
66
  }
52
- const sourceIsStale = () => {
53
- const newestSource = srcRoots.reduce((newest, root) => {
67
+ const newestRuntimeSource = (root) => {
68
+ const roots = buildTargets.map(([packagePath]) => join(root, packagePath, 'src'))
69
+ return roots.reduce((newest, root) => {
54
70
  if (!existsSync(root)) return newest
55
71
  for (const entry of readdirSync(root, { recursive: true })) {
56
72
  const path = join(root, String(entry))
@@ -60,6 +76,94 @@ if (existsSync(sourceRoot)) {
60
76
  }
61
77
  return newest
62
78
  }, 0)
79
+ }
80
+ const newestPackageSource = (root, packagePath) => {
81
+ const source = join(root, packagePath, 'src')
82
+ if (!existsSync(source)) return 0
83
+ let newest = 0
84
+ const visit = (dir) => {
85
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ const path = join(dir, entry.name)
87
+ if (entry.isDirectory()) visit(path)
88
+ else if (isRuntimeSource(path)) {
89
+ try { newest = Math.max(newest, statSync(path).mtimeMs) } catch { /* retry on the next launch */ }
90
+ }
91
+ }
92
+ }
93
+ visit(source)
94
+ return newest
95
+ }
96
+ const entriesAreFresh = (root) => {
97
+ const newestSource = newestRuntimeSource(root)
98
+ return buildTargets.every(([packagePath, entry]) => {
99
+ try { return statSync(join(root, packagePath, entry)).mtimeMs >= newestSource } catch { return false }
100
+ })
101
+ }
102
+ const packageFingerprint = (root, packagePath) => {
103
+ const hash = createHash('sha256')
104
+ const packageRoot = join(root, packagePath)
105
+ const files = ['package.json', 'tsconfig.build.json'].map((name) => join(packageRoot, name)).filter(existsSync)
106
+ const visit = (dir, relative = '') => {
107
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
108
+ const path = join(dir, entry.name)
109
+ const child = relative ? `${relative}/${entry.name}` : entry.name
110
+ if (entry.isDirectory()) visit(path, child)
111
+ else if (isRuntimeSource(path)) files.push(path)
112
+ }
113
+ }
114
+ const source = join(packageRoot, 'src')
115
+ if (existsSync(source)) visit(source)
116
+ files.sort()
117
+ for (const path of files) hash.update(path.slice(packageRoot.length)).update('\0').update(readFileSync(path)).update('\0')
118
+ return hash.digest('hex')
119
+ }
120
+ const packageDependents = {
121
+ 'packages/session-protocol': ['packages/session-topology', 'packages/session-runtime', 'packages/session-events', 'packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
122
+ 'packages/session-topology': ['packages/session-application', 'spec-cli'],
123
+ 'packages/session-runtime': ['packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
124
+ 'packages/session-events': ['packages/session-application', 'spec-cli'],
125
+ 'packages/session-application': ['spec-cli'],
126
+ 'packages/session-selflaunch': ['spec-cli'],
127
+ 'packages/spec-core': ['spec-eval', 'spec-forge', 'spec-cli'],
128
+ 'spec-eval': ['spec-cli'],
129
+ 'spec-forge': ['spec-cli'],
130
+ 'spec-cli': [],
131
+ }
132
+ const changedBuildPackages = (root) => {
133
+ const changed = new Set(buildTargets
134
+ .map(([packagePath]) => packagePath)
135
+ .filter((packagePath) => packageFingerprint(workspace, packagePath) !== packageFingerprint(root, packagePath)))
136
+ for (const [packagePath, entry] of buildTargets) {
137
+ try {
138
+ if (statSync(join(root, packagePath, entry)).mtimeMs < newestPackageSource(workspace, packagePath)) changed.add(packagePath)
139
+ } catch { changed.add(packagePath) }
140
+ }
141
+ for (const packagePath of [...changed]) for (const dependent of packageDependents[packagePath]) changed.add(dependent)
142
+ return buildTargets.map(([packagePath]) => packagePath).filter((packagePath) => changed.has(packagePath))
143
+ }
144
+ const mainRoot = (() => {
145
+ const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: workspace, encoding: 'utf8' })
146
+ if (result.status !== 0) return null
147
+ const commonDir = result.stdout.trim()
148
+ return commonDir ? dirname(commonDir) : null
149
+ })()
150
+ const reusableMainBuild = () => mainRoot && mainRoot !== workspace && entriesAreFresh(mainRoot)
151
+ const importMainBuild = () => {
152
+ for (const [packagePath] of buildTargets) {
153
+ const source = join(mainRoot, packagePath, 'dist')
154
+ const target = join(workspace, packagePath, 'dist')
155
+ const temporary = `${target}.from-main-${process.pid}`
156
+ const previous = `${target}.from-main-previous-${process.pid}`
157
+ rmSync(temporary, { recursive: true, force: true })
158
+ rmSync(previous, { recursive: true, force: true })
159
+ cpSync(source, temporary, { recursive: true })
160
+ if (existsSync(target)) renameSync(target, previous)
161
+ renameSync(temporary, target)
162
+ rmSync(previous, { recursive: true, force: true })
163
+ }
164
+ }
165
+ const sourceIsStale = () => {
166
+ const newestSource = newestRuntimeSource(workspace)
63
167
  return runtimeEntries.some((entry) => {
64
168
  try { return statSync(entry).mtimeMs < newestSource } catch { return true }
65
169
  })
@@ -90,10 +194,23 @@ if (existsSync(sourceRoot)) {
90
194
  }
91
195
  try {
92
196
  if (sourceIsStale()) {
93
- const build = spawnSync('npm', ['run', 'build'], { cwd: workspace, stdio: 'inherit' })
94
- if (build.error || build.status !== 0 || !existsSync(cli)) {
95
- console.error('spex: source workspace build failed; fix it, then retry.')
96
- process.exit(build.status ?? 1)
197
+ if (reusableMainBuild()) {
198
+ const changed = changedBuildPackages(mainRoot)
199
+ console.error(`spex: importing fresh runtime closure from main; rebuilding ${changed.length} package(s)`)
200
+ importMainBuild()
201
+ if (changed.length) {
202
+ const build = spawnSync(process.execPath, [join(workspace, 'scripts', 'build-workspaces.mjs'), ...changed], { cwd: workspace, stdio: 'inherit' })
203
+ if (build.error || build.status !== 0 || !existsSync(cli)) {
204
+ console.error('spex: source workspace selective build failed; fix it, then retry.')
205
+ process.exit(build.status ?? 1)
206
+ }
207
+ }
208
+ } else {
209
+ const build = spawnSync('npm', ['run', 'build'], { cwd: workspace, stdio: 'inherit' })
210
+ if (build.error || build.status !== 0 || !existsSync(cli)) {
211
+ console.error('spex: source workspace build failed; fix it, then retry.')
212
+ process.exit(build.status ?? 1)
213
+ }
97
214
  }
98
215
  }
99
216
  } finally {
@@ -1831,6 +1831,25 @@ else if (cmd === 'internal') {
1831
1831
  process.exit(1);
1832
1832
  }
1833
1833
  }
1834
+ else if (sub === 'codex-reopen') {
1835
+ // A headless resume reloads its evicted thread into the shared app-server (thread/resume, no turn), so
1836
+ // readiness — which proves online only for a RESIDENT thread — can see it. The visible TUI does this
1837
+ // implicitly by attaching; headless has no TUI, so its launch.sh resume branch calls this.
1838
+ const { codexReopenThread } = await import('./harness.js');
1839
+ const sock = process.argv[4], tid = process.argv[5];
1840
+ if (!sock || !tid) {
1841
+ console.error('usage: spex internal codex-reopen <sock> <threadId>');
1842
+ process.exit(2);
1843
+ }
1844
+ const r = await codexReopenThread(sock, tid);
1845
+ if (r.ok) {
1846
+ console.log('reopened');
1847
+ }
1848
+ else {
1849
+ console.error(r.error);
1850
+ process.exit(1);
1851
+ }
1852
+ }
1834
1853
  else if (sub === 'check-staged') {
1835
1854
  // the pre-commit hook's eval backstop: a staged stray evidence blob or malformed eval.md rejects the
1836
1855
  // commit. Logic lives in spec-eval; the hook shims here.
@@ -1,15 +1,27 @@
1
- import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, copyFileSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ // A differing target is replaced by rename, never rewritten in place: a concurrent reader — codex re-reading its
4
+ // own config.toml, a harness loading its hooks file — sees the old bytes or the new bytes, never a truncated
5
+ // middle. The replacement lands on the real file behind a symlink and keeps that file's mode.
2
6
  export function writeFileIfChanged(path, content) {
3
7
  const next = Buffer.from(content);
8
+ let target = path;
9
+ let mode;
4
10
  try {
5
11
  if (readFileSync(path).equals(next))
6
12
  return false;
13
+ target = realpathSync(path);
14
+ mode = statSync(target).mode;
7
15
  }
8
16
  catch (error) {
9
17
  if (error?.code !== 'ENOENT')
10
18
  throw error;
11
19
  }
12
- writeFileSync(path, content);
20
+ const staged = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
21
+ writeFileSync(staged, next);
22
+ if (mode !== undefined)
23
+ chmodSync(staged, mode);
24
+ renameSync(staged, target);
13
25
  return true;
14
26
  }
15
27
  export function copyFileIfChanged(source, target) {
@@ -307,7 +307,7 @@ PORTABILITY, and picking the right one is the whole discipline:
307
307
  below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
308
308
 
309
309
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
310
- icon or launcher-visibility policy, upload policy, lint policy, resource and doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
310
+ icon, upload policy, lint policy, resource and doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
311
311
  PATH of a launcher wrapper or a TLS cert path are machine facts → gitignored spexcode.local.json.
312
312
  Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
313
313
  \`spex session new\` or the dashboard without an explicit launcher choice.
@@ -339,13 +339,8 @@ Example — a repo whose trunk is \`staging\`, not \`main\`:
339
339
  dashboard.apiUrl the per-project backend the dashboard proxies to (read frontend-side). For a SHARED
340
340
  install prefer the API_URL env var; apiUrl here is the default only when the dashboard
341
341
  lives inside the project.
342
- dashboard.showHeadlessLaunchers
343
- include launchers whose harness declares itself headless in the dashboard New Session
344
- picker. Default: false. This changes dashboard visibility only; explicit CLI
345
- --launcher selection can still use every configured launcher.
346
342
  Example:
347
- { "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch",
348
- "showHeadlessLaunchers": false } }
343
+ { "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch" } }
349
344
 
350
345
  ── HOST GATEWAY ($SPEXCODE_HOME/config.json — per-user host identity, never a project file) ──
351
346
  gateway.icon the global /projects icon, using the same preset ids above. Default: "gateway".
@@ -403,14 +398,17 @@ A named launcher profile fixes BOTH a session's harness AND its exact launch com
403
398
  by name with --launcher/the dashboard dropdown, and the chosen name is persisted on the record so a resume
404
399
  reuses the same auth. There are NO magic built-ins: \`spex init\` SEEDS an ordinary named launcher for each
405
400
  harness the adopter SELECTED (--harness), from the template pool
406
- "claude" → { "harness": "claude", "cmd": "claude" }
407
401
  "claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
408
- "codex" → { "harness": "codex", "cmd": "codex" }
402
+ "claude" → { "harness": "claude", "cmd": "claude" }
409
403
  "codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
410
- "opencode" → { "harness": "opencode", "cmd": "opencode" }
404
+ "codex" → { "harness": "codex", "cmd": "codex" }
411
405
  "opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
412
- "pi" → { "harness": "pi", "cmd": "pi" }
406
+ "opencode" → { "harness": "opencode", "cmd": "opencode" }
413
407
  "pi-headless" → { "harness": "pi-headless", "cmd": "pi" }
408
+ "pi" → { "harness": "pi", "cmd": "pi" }
409
+ Each harness's headless (terminal-free) form leads its interactive one, and \`spex init\` makes the FIRST
410
+ planted launcher the default — so an adoption that selects both forms of a harness creates its sessions
411
+ headless by default, read through the dashboard's Conversation; the interactive TUI launcher is the opt-in.
414
412
  The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` and
415
413
  \`codex-headless\` are deliberate seed exceptions: their terminal-free runs require \`opencode --auto\` and
416
414
  \`codex --yolo\`; interactive profiles stay plain. Other automatic-permission commands are NEVER clean-init
@@ -1,6 +1,5 @@
1
1
  import { type SlashCommand } from './slash-commands.js';
2
- import { type ExecutionTrace, type ExecutionTurn } from './execution-trace.js';
3
- import { type TranscriptRead, type TranscriptRange } from './transcript-reader.js';
2
+ import { type TranscriptReader } from '@spexcode/transcript';
4
3
  import { type HarnessId } from '@spexcode/spec-core';
5
4
  export type { HarnessId } from '@spexcode/spec-core';
6
5
  export type HarnessLivenessRecord = {
@@ -123,8 +122,7 @@ export interface Harness {
123
122
  readonly events: readonly string[];
124
123
  readonly ownsRendezvous: boolean;
125
124
  readonly paneTitleIsSelfSummary: boolean;
126
- executionTrace(threadId: string, turn: ExecutionTurn | null): ExecutionTrace | null;
127
- readTranscript(threadId: string, range: TranscriptRange): Promise<TranscriptRead>;
125
+ readonly transcript: TranscriptReader;
128
126
  launchCmd(id: string, runtimeDir?: string, cmd?: string): string;
129
127
  baseCmd(cmd?: string): string;
130
128
  oneShotTurn?(prompt: string, cmd?: string): {
@@ -234,6 +232,9 @@ export declare const codexAppServerReceipt: (dir?: string) => string;
234
232
  export declare function deliverViaRendezvous(id: string, text: string, mid?: string, wallMs?: number): Promise<DispatchResult>;
235
233
  export declare function deliverViaClaudeRendezvous(id: string, text: string, mid?: string, runtimeDir?: string): Promise<DispatchResult>;
236
234
  export declare function deliverViaSocketOrWake(id: string, text: string, mid: string | undefined, coldWake: () => Promise<DispatchResult>, unprovenError: string): Promise<DispatchResult>;
235
+ export declare function interruptViaRendezvous(id: string, harness: string, opts?: {
236
+ settle?: boolean;
237
+ }): Promise<DispatchResult>;
237
238
  type JsonRpc = {
238
239
  id?: number;
239
240
  method?: string;
@@ -261,6 +262,12 @@ export declare function sessionIdentityEnvVars(): string[];
261
262
  export declare function codexLaunchCommand(id: string, codexCmd?: string, serverCmd?: string, dir?: string, attachTui?: boolean): string;
262
263
  export declare const CODEX_TURN_OBSERVER_SUBSCRIBE_MS = 30000;
263
264
  export declare function codexTurnFailureObserver(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription;
265
+ export declare function codexReopenThread(sock: string, threadId: string, budgetMs?: number): Promise<{
266
+ ok: true;
267
+ } | {
268
+ ok: false;
269
+ error: string;
270
+ }>;
264
271
  export declare const CODEX_THREAD_SOURCE_KINDS: readonly ["cli", "vscode", "exec", "appServer", "subAgent", "subAgentReview", "subAgentCompact", "subAgentThreadSpawn", "subAgentOther", "unknown"];
265
272
  export declare function codexLoadedReferenceIds(sock: string): Promise<{
266
273
  ok: true;
@@ -343,7 +350,6 @@ export type LauncherDefault = {
343
350
  error: string | null;
344
351
  };
345
352
  export declare function launcherList(root?: string): Launcher[];
346
- export declare function dashboardLauncherList(root?: string): Launcher[];
347
353
  export declare 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\"}})";
348
354
  export declare function defaultLauncher(root?: string): string;
349
355
  export declare function launcherDefault(root?: string): LauncherDefault;