@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.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 (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -5,10 +5,11 @@
5
5
  * deploy line) was wiped by the upstream reset and cannot be re-applied
6
6
  * without re-forking apps/cli on every release — which we cannot upstream
7
7
  * (no PR access). This runner replaces it WITHOUT touching the harness: it
8
- * resolves the official published packages (`@deepseek-ai/dsh-app-boot`,
9
- * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from the LIVE
10
- * harness checkout's node_modules, so it always dry-runs the exact engine
11
- * the next boot will use and follows host updates automatically.
8
+ * resolves the official packages (`@deepseek-ai/dsh-app-boot`,
9
+ * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from an explicit
10
+ * execution binding: source files in the host checkout for a source launch,
11
+ * or the npm toolchain selected by a built launch's dsh package.json. It never
12
+ * infers that choice from Node's execArgv.
12
13
  *
13
14
  * What it verifies (same contract as the old patch):
14
15
  * - the profile's full patch stack composes (bundle layers from
@@ -27,11 +28,17 @@
27
28
  * error, env load failure): NOT a verdict on the composition.
28
29
  *
29
30
  * Run directly (any profile) or via the guard CLI:
30
- * node --import <harness>/node_modules/tsx/dist/esm/index.mjs \
31
- * packages/ankh-guard/src/preflight-runner.ts --profile web [--patch FILE]...
31
+ * node packages/ankh-guard/lib/preflight-runner.js --profile web \
32
+ * --host-surface built --install-anchor <toolchain>/node_modules/@deepseek-ai/dsh/package.json
32
33
  *
33
34
  * @module @khorsheed/dsh-ankh-guard/preflight-runner
34
35
  */
36
+ export type PreflightHostSurface = 'source' | 'built';
37
+ export interface PreflightHostBinding {
38
+ surface: PreflightHostSurface;
39
+ /** Real dsh package.json used by the successor's module graph. */
40
+ installAnchor: string;
41
+ }
35
42
  /** The guarded (or tracking) harness checkout the live instance boots from. */
36
43
  export declare function resolveHarnessRoot(env?: Record<string, string | undefined>): string;
37
44
  /** The composed composition of one profile for a preflight (or a drift check). */
@@ -49,9 +56,12 @@ export interface PreflightComposition {
49
56
  /**
50
57
  * Compose one profile's full patch stack through the launcher's layering —
51
58
  * bundle layers in `dsh.profile.bundles` order, the profile user layer, the
52
- * home-level user layer, `--patch` overlays, the agent-presets roots overlay,
53
- * then the telemetry switch. Exported so the drift tripwire can compare this
54
- * assembly against the launcher's own dump without booting anything.
59
+ * home-level user layer, `--patch` overlays, the agent-presets roots overlay
60
+ * (rc host line only; the 0.1.2 line's preset package self-ships its root),
61
+ * then the telemetry switch. Two host API generations are mirrored and
62
+ * feature-detected per run — see the `hostLine` branch below. Exported so
63
+ * the drift tripwire can compare this assembly against the launcher's own
64
+ * dump without booting anything.
55
65
  * @param profile - the profile name (same resolution as `--profile`).
56
66
  * @param patchFiles - `--patch` overlay paths, in argv order.
57
67
  * @param root - harness checkout root.
@@ -60,7 +70,7 @@ export interface PreflightComposition {
60
70
  * specific deployment must not silently compose a different home's tree.
61
71
  * @returns the patch stack and composed rows.
62
72
  */
63
- export declare function composePreflightPatches(profile: string, patchFiles: readonly string[], root: string, home?: string): Promise<PreflightComposition>;
73
+ export declare function composePreflightPatches(profile: string, patchFiles: readonly string[], root: string, home?: string, binding?: PreflightHostBinding): Promise<PreflightComposition>;
64
74
  /**
65
75
  * Boot the profile's full tree once, tear it down, and report the verdict on
66
76
  * the process streams. No HMR, no user-patch watchers, no signal wiring —
@@ -70,11 +80,12 @@ export declare function composePreflightPatches(profile: string, patchFiles: rea
70
80
  * @param root - harness checkout root (default: DSH_HARNESS or ~/code/deepseek-harness).
71
81
  * @returns the process exit code (see the module contract).
72
82
  */
73
- export declare function runPreflight(profile: string, patchFiles?: readonly string[], root?: string): Promise<number>;
74
- /** Minimal argv parse for `--profile NAME` and repeatable `--patch FILE`. */
83
+ export declare function runPreflight(profile: string, patchFiles?: readonly string[], root?: string, binding?: PreflightHostBinding): Promise<number>;
84
+ /** Minimal argv parse; the host execution surface is mandatory and explicit. */
75
85
  export declare function parsePreflightArgs(argv: readonly string[]): {
76
86
  profile: string;
77
87
  patchFiles: string[];
88
+ binding?: PreflightHostBinding;
78
89
  error?: string;
79
90
  };
80
91
  //# sourceMappingURL=preflight-runner.d.ts.map
@@ -5,10 +5,11 @@
5
5
  * deploy line) was wiped by the upstream reset and cannot be re-applied
6
6
  * without re-forking apps/cli on every release — which we cannot upstream
7
7
  * (no PR access). This runner replaces it WITHOUT touching the harness: it
8
- * resolves the official published packages (`@deepseek-ai/dsh-app-boot`,
9
- * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from the LIVE
10
- * harness checkout's node_modules, so it always dry-runs the exact engine
11
- * the next boot will use and follows host updates automatically.
8
+ * resolves the official packages (`@deepseek-ai/dsh-app-boot`,
9
+ * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from an explicit
10
+ * execution binding: source files in the host checkout for a source launch,
11
+ * or the npm toolchain selected by a built launch's dsh package.json. It never
12
+ * infers that choice from Node's execArgv.
12
13
  *
13
14
  * What it verifies (same contract as the old patch):
14
15
  * - the profile's full patch stack composes (bundle layers from
@@ -27,8 +28,8 @@
27
28
  * error, env load failure): NOT a verdict on the composition.
28
29
  *
29
30
  * Run directly (any profile) or via the guard CLI:
30
- * node --import <harness>/node_modules/tsx/dist/esm/index.mjs \
31
- * packages/ankh-guard/src/preflight-runner.ts --profile web [--patch FILE]...
31
+ * node packages/ankh-guard/lib/preflight-runner.js --profile web \
32
+ * --host-surface built --install-anchor <toolchain>/node_modules/@deepseek-ai/dsh/package.json
32
33
  *
33
34
  * @module @khorsheed/dsh-ankh-guard/preflight-runner
34
35
  */
@@ -40,9 +41,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
40
41
  }
41
42
  return path;
42
43
  };
43
- import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs';
44
+ import { existsSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
44
45
  import { homedir, tmpdir } from 'node:os';
45
- import { join, resolve } from 'node:path';
46
+ import { createRequire } from 'node:module';
47
+ import { dirname, join, resolve } from 'node:path';
46
48
  import { pathToFileURL } from 'node:url';
47
49
  import { isDirectInvocation } from "./defaults.js";
48
50
  const NAME = 'dsh';
@@ -50,6 +52,17 @@ const PROFILE_ROOT_FILENAME = 'cordis.yml';
50
52
  const HOME_PATCH_FILENAME = 'cordis.patch.yml';
51
53
  const TELEMETRY_ROW_ID = 'session-telemetry-otel';
52
54
  const DSH_HARNESS_ENV = 'DSH_HARNESS';
55
+ /**
56
+ * The empty root entry list every profile tree patches over — the exact
57
+ * bytes the launcher's prepareProfile rewrites on every boot (the vendored
58
+ * Loader's tree write-back can bake composed rows into this file, so both
59
+ * the launcher and this dry-run always start from the empty root).
60
+ */
61
+ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
62
+ # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
63
+ # --patch overlays. Edit cordis.patch.yml, not this file.
64
+ []
65
+ `;
53
66
  /** The guarded (or tracking) harness checkout the live instance boots from. */
54
67
  export function resolveHarnessRoot(env = process.env) {
55
68
  const fromEnv = env[DSH_HARNESS_ENV];
@@ -67,18 +80,9 @@ const HARNESS_PACKAGE_DIRS = {
67
80
  '@deepseek-ai/dsh-cmdline': 'packages/boot/cmdline',
68
81
  };
69
82
  /**
70
- * Whether this runtime can import TypeScript sources (the runner is launched
71
- * through tsx; a plain-node consumer of the published package cannot).
72
- */
73
- function canImportTypeScript() {
74
- return process.execArgv.some(arg => arg.includes('tsx'));
75
- }
76
- /**
77
- * Dynamically import a harness package from the live checkout, SOURCE FIRST:
78
- * a source-launched prod instance (tsx … apps/cli/src/bin.ts) boots the
79
- * source, so the preflight engine must be the source too — a stale `lib/`
80
- * must never make the preflight greener than the real boot. The built entry
81
- * is the fallback for harnesses that ship only artifacts.
83
+ * Dynamically import one host package from the explicitly selected surface.
84
+ * A source launch uses checkout source and fails rather than falling back to
85
+ * stale lib; a built launch resolves only through its npm install anchor.
82
86
  *
83
87
  * The composition layers below (bundle layers, user layers, overlays, the
84
88
  * agent-presets roots, the telemetry switch) mirror the launcher's private
@@ -90,27 +94,34 @@ function canImportTypeScript() {
90
94
  * @param name - package name (a key of {@link HARNESS_PACKAGE_DIRS}).
91
95
  * @returns the imported module.
92
96
  */
93
- async function loadHarnessPackage(root, name) {
97
+ async function loadHarnessPackage(root, name, binding) {
98
+ if (binding.surface === 'built') {
99
+ let entry;
100
+ try {
101
+ entry = createRequire(binding.installAnchor).resolve(name);
102
+ }
103
+ catch (error) {
104
+ throw new Error(`built host package ${name} is not resolvable from ${binding.installAnchor}: ${String(error)}`, { cause: error });
105
+ }
106
+ return await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
107
+ }
94
108
  const relative = HARNESS_PACKAGE_DIRS[name];
95
109
  if (relative === undefined)
96
110
  throw new Error(`no known harness layout entry for ${name}`);
97
111
  const base = join(root, relative);
98
112
  const source = join(base, 'src', 'index.ts');
99
- if (canImportTypeScript() && existsSync(source)) {
100
- try {
101
- return await import(__rewriteRelativeImportExtension(pathToFileURL(source).href));
102
- }
103
- catch (error) {
104
- // A broken source import (syntax error, unresolved workspace dep) is
105
- // EXACTLY what the next source boot would hit — surface it as a
106
- // composition verdict, never silently fall back to a stale build.
107
- throw new Error(`harness source ${source} failed to import (this is what a source boot would hit): ${String(error)}`, { cause: error });
108
- }
113
+ if (!existsSync(source)) {
114
+ throw new Error(`harness source entry does not exist: ${source}`);
115
+ }
116
+ try {
117
+ return await import(__rewriteRelativeImportExtension(pathToFileURL(source).href));
118
+ }
119
+ catch (error) {
120
+ // A broken source import (syntax error, unresolved workspace dep) is
121
+ // EXACTLY what the next source boot would hit — surface it as a
122
+ // composition verdict, never silently fall back to a stale build.
123
+ throw new Error(`harness source ${source} failed to import (this is what a source boot would hit): ${String(error)}`, { cause: error });
109
124
  }
110
- const manifest = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8'));
111
- const entry = manifest.main ?? 'lib/index.js';
112
- const module = await import(__rewriteRelativeImportExtension(pathToFileURL(join(base, entry)).href));
113
- return module;
114
125
  }
115
126
  /** Thrown for harness-side load failures — preflight infrastructure, never a composition verdict. */
116
127
  class PreflightInfraError extends Error {
@@ -118,9 +129,12 @@ class PreflightInfraError extends Error {
118
129
  /**
119
130
  * Compose one profile's full patch stack through the launcher's layering —
120
131
  * bundle layers in `dsh.profile.bundles` order, the profile user layer, the
121
- * home-level user layer, `--patch` overlays, the agent-presets roots overlay,
122
- * then the telemetry switch. Exported so the drift tripwire can compare this
123
- * assembly against the launcher's own dump without booting anything.
132
+ * home-level user layer, `--patch` overlays, the agent-presets roots overlay
133
+ * (rc host line only; the 0.1.2 line's preset package self-ships its root),
134
+ * then the telemetry switch. Two host API generations are mirrored and
135
+ * feature-detected per run — see the `hostLine` branch below. Exported so
136
+ * the drift tripwire can compare this assembly against the launcher's own
137
+ * dump without booting anything.
124
138
  * @param profile - the profile name (same resolution as `--profile`).
125
139
  * @param patchFiles - `--patch` overlay paths, in argv order.
126
140
  * @param root - harness checkout root.
@@ -129,12 +143,15 @@ class PreflightInfraError extends Error {
129
143
  * specific deployment must not silently compose a different home's tree.
130
144
  * @returns the patch stack and composed rows.
131
145
  */
132
- export async function composePreflightPatches(profile, patchFiles, root, home) {
146
+ export async function composePreflightPatches(profile, patchFiles, root, home, binding = {
147
+ surface: 'source',
148
+ installAnchor: join(root, 'apps', 'cli', 'package.json'),
149
+ }) {
133
150
  let appBoot;
134
151
  let homePaths;
135
152
  try {
136
- appBoot = await loadHarnessPackage(root, '@deepseek-ai/dsh-app-boot');
137
- homePaths = await loadHarnessPackage(root, '@deepseek-ai/dsh-home-paths');
153
+ appBoot = await loadHarnessPackage(root, '@deepseek-ai/dsh-app-boot', binding);
154
+ homePaths = await loadHarnessPackage(root, '@deepseek-ai/dsh-home-paths', binding);
138
155
  }
139
156
  catch (error) {
140
157
  throw new PreflightInfraError(`harness packages unavailable under ${root}: ${String(error)}`, { cause: error });
@@ -151,10 +168,30 @@ export async function composePreflightPatches(profile, patchFiles, root, home) {
151
168
  // healing then re-points fallback links into self-referential loops
152
169
  // (observed on a second preflight from the installed CLI: ~20 links looped,
153
170
  // the next real boot would have failed).
154
- const anchor = join(root, 'apps', 'cli', 'package.json');
171
+ const anchor = binding.installAnchor;
155
172
  const resolvedHome = resolveDshHome(home);
156
- healProfilesModuleFallback(anchor);
173
+ // Which app-boot API generation this host speaks. The 0.1.2 line re-layered
174
+ // the profile composition: the heal moved behind the async options API and
175
+ // below the profile load (see HealProfilesModuleFallback), and the launcher
176
+ // dropped its agent-presets shipped-root overlay because the preset package
177
+ // now self-ships its root. DEFAULT_PROFILE_PATCH_RELOAD is a value export
178
+ // only the new line carries, so it is the feature marker; a version parse
179
+ // would break on exactly the unreleased builds this runner must dry-run.
180
+ // Both lines stay supported: prod hosts run the rc line until 0.1.2 lands
181
+ // on npm.
182
+ const hostLine = 'DEFAULT_PROFILE_PATCH_RELOAD' in appBoot ? '0.1.2' : 'rc';
183
+ if (hostLine === 'rc')
184
+ healProfilesModuleFallback(anchor, resolvedHome);
157
185
  const composed = loadProfile(NAME, profile, anchor, resolvedHome, { userLayer: true });
186
+ // Mirror prepareProfile: rewrite the empty root config the tree patches
187
+ // over. The Loader needs the real file to anchor the include, and on a
188
+ // fresh home (the launcher never booted it) the file does not exist yet —
189
+ // the dry-run would otherwise fail on exactly the tree a first boot
190
+ // composes fine.
191
+ writeFileSync(join(composed.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
192
+ if (hostLine === '0.1.2') {
193
+ await healProfilesModuleFallback({ installAnchor: anchor, profile: composed, home: resolvedHome });
194
+ }
158
195
  const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
159
196
  const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)));
160
197
  const bundlePatches = composed.layers.flatMap(layer => layer.patches);
@@ -165,12 +202,19 @@ export async function composePreflightPatches(profile, patchFiles, root, home) {
165
202
  rows.set(row.id, row);
166
203
  }
167
204
  const composedOverlays = [...overlays];
168
- if (rows.has('agent-presets')) {
205
+ // The launcher's shipped preset root exists only on the rc line — the
206
+ // 0.1.2 line removed apps/cli/config/agent-presets and lets the preset
207
+ // package self-ship its root, so the overlay follows the directory, not
208
+ // the host line.
209
+ const shippedPresetRoot = binding.surface === 'built'
210
+ ? join(dirname(binding.installAnchor), 'config', 'agent-presets')
211
+ : join(root, 'apps', 'cli', 'config', 'agent-presets');
212
+ if (rows.has('agent-presets') && existsSync(shippedPresetRoot)) {
169
213
  composedOverlays.push({
170
214
  id: 'agent-presets',
171
215
  config: {
172
216
  ...(rows.get('agent-presets')?.config ?? {}),
173
- roots: [{ path: join(root, 'apps/cli/config/agent-presets/'), trust: 'system' }],
217
+ roots: [{ path: shippedPresetRoot, trust: 'system' }],
174
218
  },
175
219
  });
176
220
  }
@@ -210,6 +254,36 @@ export async function composePreflightPatches(profile, patchFiles, root, home) {
210
254
  patches.push(...composedOverlays);
211
255
  return { patches, rows, profileDir: composed.dir };
212
256
  }
257
+ /**
258
+ * The launcher's readiness signal, mirrored: 0.1.2's runProfile provides an
259
+ * `appReady` service through provideCmdline and commits it once boot and host
260
+ * setup settle. The rc line's provideCmdline ignores the field, so one
261
+ * implementation serves both host lines.
262
+ */
263
+ function createAppReadyStub() {
264
+ let committed = false;
265
+ const listeners = new Set();
266
+ return {
267
+ service: {
268
+ onReady(listener) {
269
+ if (committed) {
270
+ listener();
271
+ return () => { };
272
+ }
273
+ listeners.add(listener);
274
+ return () => { listeners.delete(listener); };
275
+ },
276
+ },
277
+ commit() {
278
+ if (committed)
279
+ return;
280
+ committed = true;
281
+ for (const listener of [...listeners])
282
+ listener();
283
+ listeners.clear();
284
+ },
285
+ };
286
+ }
213
287
  /** Stat every registered client bundle; report one line per missing/unreadable artifact. */
214
288
  function missingClientArtifacts(ctx) {
215
289
  const registry = ctx.get?.('clientModules');
@@ -240,16 +314,19 @@ function missingClientArtifacts(ctx) {
240
314
  * @param root - harness checkout root (default: DSH_HARNESS or ~/code/deepseek-harness).
241
315
  * @returns the process exit code (see the module contract).
242
316
  */
243
- export async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot()) {
317
+ export async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(), binding = {
318
+ surface: 'source',
319
+ installAnchor: join(root, 'apps', 'cli', 'package.json'),
320
+ }) {
244
321
  // Environment loading sits outside the composition pipeline; a failure here
245
322
  // is preflight infrastructure, not a verdict on the tree.
246
323
  let appBoot;
247
324
  let launchEnvironment;
248
325
  let cmdline;
249
326
  try {
250
- appBoot = await loadHarnessPackage(root, '@deepseek-ai/dsh-app-boot');
251
- launchEnvironment = await loadHarnessPackage(root, '@deepseek-ai/dsh-launch-environment');
252
- cmdline = await loadHarnessPackage(root, '@deepseek-ai/dsh-cmdline');
327
+ appBoot = await loadHarnessPackage(root, '@deepseek-ai/dsh-app-boot', binding);
328
+ launchEnvironment = await loadHarnessPackage(root, '@deepseek-ai/dsh-launch-environment', binding);
329
+ cmdline = await loadHarnessPackage(root, '@deepseek-ai/dsh-cmdline', binding);
253
330
  }
254
331
  catch (error) {
255
332
  process.stderr.write(`preflight could not execute (harness packages unavailable under ${root}): ${error instanceof Error ? error.message : String(error)}\n`);
@@ -268,7 +345,7 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
268
345
  return 3;
269
346
  }
270
347
  try {
271
- const composed = await composePreflightPatches(profile, patchFiles, root);
348
+ const composed = await composePreflightPatches(profile, patchFiles, root, undefined, binding);
272
349
  const patches = [...composed.patches];
273
350
  const rows = composed.rows;
274
351
  // Never collide with the live instance on its configured port: 0 asks the
@@ -282,12 +359,17 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
282
359
  });
283
360
  }
284
361
  const rootConfig = join(composed.profileDir, PROFILE_ROOT_FILENAME);
362
+ const appReady = createAppReadyStub();
285
363
  // Cloned for the same insert-aliasing reason the launcher documents: boot
286
364
  // application mutates rows by reference.
287
365
  const ctx = await boot(NAME, rootConfig, structuredClone(patches), (hostCtx) => {
288
366
  hostCtx.provide?.(launchEnvironmentKey, environment);
289
- provideCmdline(hostCtx, { args: [], exit: () => { } });
367
+ provideCmdline(hostCtx, { args: [], exit: () => { }, ready: appReady.service });
290
368
  });
369
+ // The launcher commits readiness once boot and host setup settle; a
370
+ // dry-run's host setup is the no-op cmdline above, so boot settling is
371
+ // that point.
372
+ appReady.commit();
291
373
  const missing = missingClientArtifacts(ctx);
292
374
  // A repeated dispose returns the settled single-shot result when boot
293
375
  // already tore the tree down, so this is safe on every path.
@@ -310,10 +392,12 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
310
392
  return 1;
311
393
  }
312
394
  }
313
- /** Minimal argv parse for `--profile NAME` and repeatable `--patch FILE`. */
395
+ /** Minimal argv parse; the host execution surface is mandatory and explicit. */
314
396
  export function parsePreflightArgs(argv) {
315
397
  const patchFiles = [];
316
398
  let profile = '';
399
+ let surface = '';
400
+ let installAnchor = '';
317
401
  for (let i = 0; i < argv.length; i++) {
318
402
  const arg = argv[i];
319
403
  if (arg === '--profile') {
@@ -324,20 +408,31 @@ export function parsePreflightArgs(argv) {
324
408
  if (file !== undefined)
325
409
  patchFiles.push(file);
326
410
  }
411
+ else if (arg === '--host-surface') {
412
+ surface = argv[++i] ?? '';
413
+ }
414
+ else if (arg === '--install-anchor') {
415
+ installAnchor = argv[++i] ?? '';
416
+ }
327
417
  else if (arg === '--help' || arg === '-h') {
328
- return { profile: '', patchFiles: [], error: 'usage: preflight-runner --profile <name> [--patch FILE]...' };
418
+ return { profile: '', patchFiles: [], error: 'usage: preflight-runner --profile <name> --host-surface source|built --install-anchor FILE [--patch FILE]...' };
329
419
  }
330
420
  }
331
421
  if (profile === '')
332
422
  return { profile: '', patchFiles: [], error: 'preflight requires --profile <name>' };
333
- return { profile, patchFiles };
423
+ if (surface !== 'source' && surface !== 'built') {
424
+ return { profile, patchFiles, error: 'preflight requires --host-surface source|built' };
425
+ }
426
+ if (installAnchor === '')
427
+ return { profile, patchFiles, error: 'preflight requires --install-anchor FILE' };
428
+ return { profile, patchFiles, binding: { surface, installAnchor } };
334
429
  }
335
430
  // Standalone entry: only when executed directly (not imported by the CLI).
336
431
  if (isDirectInvocation(import.meta.url)) {
337
- const { profile, patchFiles, error } = parsePreflightArgs(process.argv.slice(2));
338
- if (error !== undefined) {
432
+ const { profile, patchFiles, binding, error } = parsePreflightArgs(process.argv.slice(2));
433
+ if (error !== undefined || binding === undefined) {
339
434
  process.stderr.write(`${error}\n`);
340
435
  process.exit(2);
341
436
  }
342
- process.exitCode = await runPreflight(profile, patchFiles);
437
+ process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
343
438
  }
@@ -1,5 +1,40 @@
1
+ /** A PID plus the kernel-visible process start instant used to reject PID reuse. */
2
+ export interface ProcessIdentity {
3
+ pid: number;
4
+ startToken: string;
5
+ }
6
+ /** The old supervisor's exact child root and the listener inside that tree. */
7
+ export interface OwnedListener {
8
+ child: ProcessIdentity;
9
+ listener: ProcessIdentity;
10
+ }
11
+ /** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
12
+ export declare function findPidsOnPort(port: number): number[];
13
+ /** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
14
+ export declare function listeningPortsForPid(pid: number): number[];
1
15
  /** The first process listening on a TCP port, or null when none is (via lsof). */
2
16
  export declare function findPidOnPort(port: number): string | null;
17
+ /**
18
+ * Current identity for a live process, or null when it cannot be proved.
19
+ * Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
20
+ * so bind the start instant to the boot, uid, session, and settled command.
21
+ * This materially strengthens macOS ps(1)'s second-granularity lstart; all
22
+ * guard captures occur after the watchdog/child has reached its steady argv.
23
+ */
24
+ export declare function processIdentity(pid: number): ProcessIdentity | null;
25
+ /** Whether the same, non-recycled process is still alive. */
26
+ export declare function processIdentityMatches(identity: ProcessIdentity): boolean;
27
+ /** The live POSIX process-group id for one PID, or null when unavailable. */
28
+ export declare function processGroupId(pid: number): number | null;
29
+ /** Whether candidate is root itself or a live descendant of root. */
30
+ export declare function pidBelongsToTree(root: number, candidate: number): boolean;
31
+ /**
32
+ * Prove that exactly one port listener belongs to a supervisor and return the
33
+ * direct child root through which that supervisor owns it. This is captured
34
+ * before cutover; the successor must stop this identity, never an arbitrary
35
+ * process discovered later from the shared port.
36
+ */
37
+ export declare function findOwnedListener(port: number, supervisorPid: number): OwnedListener | null;
3
38
  /**
4
39
  * Discover how the process on a port was launched — its exact argv from
5
40
  * `ps -o command=`, its cwd from lsof, and its DSH_* environment from
@@ -12,12 +47,9 @@ export declare function findPidOnPort(port: number): string | null;
12
47
  */
13
48
  export declare function discoverLaunchCommand(pid: string): string | null;
14
49
  /**
15
- * Kill a pid AND its descendants, deepest first (best effort). The supervised
16
- * instance may have forked children; a plain signal on the pid alone would
17
- * orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
18
- * for). The process-group model is NOT assumed — the instance is not
19
- * setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
20
- * returning nothing is fine: the pid itself still gets the signal.
50
+ * Freeze, then revalidate, then signal an authorized process identity. A
51
+ * mismatch resumes the PID without delivering the requested signal.
21
52
  */
53
+ export declare function signalProcessIdentity(identity: ProcessIdentity, signal: NodeJS.Signals): 'signalled' | 'gone' | 'mismatch';
22
54
  export declare function killPidTree(pid: number, signal: NodeJS.Signals): void;
23
55
  //# sourceMappingURL=processes.d.ts.map