@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * project-trust-handler — the "human directly opens pi" safety net of Trust 2층
3
+ * (0.11 Stage 0, F5b/Trust 2층). It maps a single preflight outcome to pi's
4
+ * `project_trust` result so a human at a TUI gets a consistent decision, and —
5
+ * critically — provides the ONE escape from an inherited distrust: an active
6
+ * prompt whose "yes" writes a DIRECT child trust that beats the ancestor's false.
7
+ *
8
+ * This module is the HANDLER half; the controlled-launch (launcher) half does
9
+ * NOT go through here — a controlled launch short-circuits on pi's
10
+ * `trustOverride` (`--approve`) and never reaches `project_trust` at all (N3a).
11
+ * So the active-prompt escape is, by construction, human-interactive only; an
12
+ * agent cannot self-promote trust. That asymmetry is an intended security
13
+ * property, not a gap.
14
+ *
15
+ * Boundaries baked in (GLG 6 review points, 2026-06-10):
16
+ * ① Single writer. This handler NEVER calls `store.set`. Returning
17
+ * `{trusted:"yes", remember:true}` makes pi's `resolveProjectTrusted`
18
+ * persist the decision (project-trust.js). A direct write here would be a
19
+ * double-write + lock contention.
20
+ * ② Every branch returns a value. `undefined` is forbidden — pi's runner reads
21
+ * `result.trusted` and would throw. Cancel/ESC and the no answer both map to
22
+ * a concrete result.
23
+ * ③ Non-interactive is explicit. `pi -p` (mode "print") and the rpc/json modes
24
+ * never get an active prompt; they return `undecided` so pi's own degraded
25
+ * path decides (inherited false → deny). We do not lean on the UI adapter
26
+ * happening to return undefined.
27
+ * ④ ctx.ui injection. The pure core `decideProjectTrust(outcome, ctx, prompt)`
28
+ * takes an injectable `prompt`; the adapter wires `ctx.ui.select` to it, so
29
+ * the gate drives the matrix with a fake prompt and never opens a real pi UI.
30
+ * ⑤ Alignment. The handler consumes ONLY the preflight outcome — it reads no
31
+ * file under the cwd. `prefixRoots` is an operator-policy input (no package
32
+ * default); tests inject a temp agentDir.
33
+ * ⑥ Registration is the consumer's job — see createProjectTrustHandler.
34
+ */
35
+
36
+ import type {
37
+ ProjectTrustContext,
38
+ ProjectTrustEventResult,
39
+ ProjectTrustHandler,
40
+ } from "@earendil-works/pi-coding-agent";
41
+
42
+ // ExtensionMode is not a public root export; recover it from the public
43
+ // ProjectTrustContext rather than reaching into a private subpath (frozen
44
+ // decision 9). = "tui" | "rpc" | "json" | "print".
45
+ type ExtensionMode = ProjectTrustContext["mode"];
46
+
47
+ // Explicit `.ts` extension (NOT `.js`): Node's strip-types resolver — which runs
48
+ // the gates and the bridges — resolves `.ts` specifiers literally, but refuses
49
+ // to substitute a `.ts` for a `.js` specifier (root tsconfig note). A lib→lib
50
+ // VALUE import therefore has to be `.ts` to be runnable under strip-types. Same
51
+ // fence as mcp/entwurf-bridge → entwurf-core.ts and meta-bridge-hook.ts → its
52
+ // lib: this file is excluded from the emit-capable root tsconfig (which can't
53
+ // allow .ts extensions) and typechecked by scripts/tsconfig.json instead.
54
+ import { formatPreflightDenial, type PreflightDenial, type PreflightOutcome, preflight } from "./entwurf-preflight.ts";
55
+
56
+ /** The human's answer to the inherited-distrust escape prompt. */
57
+ export type ActivePromptChoice = "trust-here" | "no" | "cancel";
58
+ /** Injectable prompt — the adapter wires this to `ctx.ui.select`. */
59
+ export type ActivePrompt = (denial: PreflightDenial) => Promise<ActivePromptChoice>;
60
+
61
+ /** Selector labels — also the gate's fake-prompt contract. */
62
+ export const TRUST_HERE_LABEL = "Trust this folder only";
63
+ export const KEEP_DISTRUSTED_LABEL = "Keep it distrusted";
64
+
65
+ /**
66
+ * Interactive = a human is at a TUI and can actually answer. "rpc"/"json" are
67
+ * programmatic and "print" is `pi -p` (headless) — never actively prompt there.
68
+ */
69
+ function isInteractive(mode: ExtensionMode, hasUI: boolean): boolean {
70
+ return hasUI && mode === "tui";
71
+ }
72
+
73
+ /** The escape-prompt title (reuses F5a evidence) + the two selectable options. */
74
+ export function formatActivePrompt(denial: PreflightDenial): { title: string; options: string[] } {
75
+ return { title: formatPreflightDenial(denial), options: [TRUST_HERE_LABEL, KEEP_DISTRUSTED_LABEL] };
76
+ }
77
+
78
+ /**
79
+ * Pure mapping: preflight outcome → project_trust result. Never undefined (②),
80
+ * never persists (①). `prompt` is only invoked for the inherited-distrust escape
81
+ * while interactive.
82
+ */
83
+ export async function decideProjectTrust(
84
+ outcome: PreflightOutcome,
85
+ ctx: { hasUI: boolean; mode: ExtensionMode },
86
+ prompt: ActivePrompt,
87
+ ): Promise<ProjectTrustEventResult> {
88
+ // approve / trusted-no-arg → yes, do NOT persist (prefix = policy SSOT, frozen
89
+ // decision 6: never dirty trust.json from a prefix auto-approve).
90
+ if (outcome.kind === "approve" || outcome.kind === "trusted-no-arg") {
91
+ return { trusted: "yes", remember: false };
92
+ }
93
+
94
+ if (outcome.reason === "saved-false") {
95
+ if (!outcome.trustStoreInherited) {
96
+ // Direct distrust on this cwd: say no, don't re-persist (already stored).
97
+ return { trusted: "no", remember: false };
98
+ }
99
+ // Inherited distrust: the ONLY escape is a human active prompt.
100
+ if (!isInteractive(ctx.mode, ctx.hasUI)) {
101
+ // ③ non-interactive: do not prompt. undecided → pi falls through to the
102
+ // store's inherited false = safe deny (pi -p degraded semantics).
103
+ return { trusted: "undecided" };
104
+ }
105
+ const choice = await prompt(outcome);
106
+ if (choice === "trust-here") {
107
+ // pi persists a DIRECT child true (single writer ①) which beats the
108
+ // inherited false (the escape direction proven in check-pi-preflight #13b).
109
+ return { trusted: "yes", remember: true };
110
+ }
111
+ if (choice === "no") {
112
+ // R3a: do not write a child false — the inherited false already covers it.
113
+ return { trusted: "no", remember: false };
114
+ }
115
+ // cancel / ESC → defer; the store's inherited false denies = safe (②).
116
+ return { trusted: "undecided" };
117
+ }
118
+
119
+ // fail-fast (undecided + trust inputs + no prefix root): defer to pi's OWN
120
+ // default prompt instead of actively prompting here. undecided lets pi prompt
121
+ // when interactive and returns false when headless — never undefined (②).
122
+ return { trusted: "undecided" };
123
+ }
124
+
125
+ export interface ProjectTrustHandlerOptions {
126
+ /** Operator-policy auto-approve roots (GLG ⑤ / frozen decision 7). No default. */
127
+ prefixRoots: readonly string[];
128
+ /** pi agent dir holding trust.json. Defaults to getAgentDir(); temp in tests. */
129
+ agentDir?: string;
130
+ }
131
+
132
+ /**
133
+ * Thin extension adapter (④/⑥): wires `event.cwd` → preflight and `ctx.ui.select`
134
+ * → the injectable prompt, then delegates to the pure core.
135
+ *
136
+ * Registration is the CONSUMER's job (agent-config), as a USER/GLOBAL extension:
137
+ * `project_trust` fires BEFORE project resources load, so a project-local
138
+ * registration (this repo's `package.json` `pi.extensions`) would load too late
139
+ * to be a safety net. The operator surface also supplies `prefixRoots` (frozen
140
+ * decision 7 — no package default).
141
+ */
142
+ export function createProjectTrustHandler(opts: ProjectTrustHandlerOptions): ProjectTrustHandler {
143
+ return async (event, ctx: ProjectTrustContext): Promise<ProjectTrustEventResult> => {
144
+ const outcome = preflight({ cwd: event.cwd, agentDir: opts.agentDir, prefixRoots: opts.prefixRoots });
145
+ const prompt: ActivePrompt = async (denial) => {
146
+ const { title, options } = formatActivePrompt(denial);
147
+ const choice = await ctx.ui.select(title, options);
148
+ if (choice === TRUST_HERE_LABEL) return "trust-here";
149
+ if (choice === KEEP_DISTRUSTED_LABEL) return "no";
150
+ return "cancel"; // undefined (ESC) or any unexpected label
151
+ };
152
+ return decideProjectTrust(outcome, { hasUI: ctx.hasUI, mode: ctx.mode }, prompt);
153
+ };
154
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * session-id — garden-native session id SSOT (`YYYYMMDDTHHMMSS-[0-9a-f]{6}`).
3
+ *
4
+ * Authored as `.js` for the exact reason protocol.js is (see its header): this
5
+ * leaf is imported from BOTH runtime paths —
6
+ * - tsc-emit path: pi-extension `.ts` files under the root tsconfig (allowJs),
7
+ * - `node --experimental-strip-types` path: pure unit gates (check-meta-session,
8
+ * new-session-id) that import a lib with a literal specifier.
9
+ * strip-types does not substitute `.ts` for a literal `.js` import specifier, and
10
+ * the root config cannot enable `allowImportingTsExtensions` without losing the
11
+ * tsc emit that check-models relies on. A real `.js` leaf resolves identically in
12
+ * every path, so the id grammar has ONE definition instead of one-per-importer.
13
+ *
14
+ * Keep dependency-free except `node:crypto`. The validator/grammar here is the
15
+ * same one the 0.9.0 resident garden guard and entwurf spawn collision pre-check
16
+ * enforce — do NOT fork it.
17
+ */
18
+
19
+ import { randomBytes } from "node:crypto";
20
+
21
+ /** `YYYYMMDDTHHMMSS-[0-9a-f]{6}`. Anchored; no surrounding slop. */
22
+ export const SESSION_ID_RE = /^\d{8}T\d{6}-[0-9a-f]{6}$/;
23
+
24
+ /**
25
+ * @param {unknown} value
26
+ * @returns {value is string}
27
+ */
28
+ export function isValidSessionId(value) {
29
+ return typeof value === "string" && SESSION_ID_RE.test(value);
30
+ }
31
+
32
+ /**
33
+ * Local (KST on operator machines) denote-style timestamp `YYYYMMDDTHHMMSS`.
34
+ * Garden sort sense. Local components on purpose — the denote corpus is local.
35
+ *
36
+ * @param {Date} [now]
37
+ * @returns {string}
38
+ */
39
+ export function formatSessionTimestamp(now = new Date()) {
40
+ const p = (n, w = 2) => String(n).padStart(w, "0");
41
+ return (
42
+ `${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}` +
43
+ `T${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Durable garden sessionId minted at the session's true birth. 6 hex suffix
49
+ * defeats same-second parallel-spawn collision; callers that spawn still
50
+ * header-scan pre-check (assertSessionIdAvailableForSpawn).
51
+ *
52
+ * @param {Date} [now]
53
+ * @returns {string}
54
+ */
55
+ export function generateSessionId(now = new Date()) {
56
+ return `${formatSessionTimestamp(now)}-${randomBytes(3).toString("hex")}`;
57
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * socket-discovery — the SOCKET-axis wiring for the fact-provider (0.11 Stage 0
3
+ * step 4, slice 3). Turns the control-socket directory + the in-domain citizen
4
+ * list into the `SocketProbe[]` that `resolveFactList` (slice 2) consumes.
5
+ *
6
+ * Why a probe per in-domain citizen, not just a directory listing: slice 2's
7
+ * frozen invariant is that EVERY in-domain (pi) citizen must arrive PROBED — a
8
+ * dormant citizen whose socket file is gone must read as `dead` (ENOENT =
9
+ * positive proof of absence) so it routes dormant→resumable, never as an
10
+ * unprobed `null`/`indeterminate` that would strand it (resolveFactList throws
11
+ * on an unprobed in-domain citizen). So we probe the union of
12
+ * (sockets present in the dir) ∪ (every in-domain citizen's canonical path):
13
+ * a dir-present socket yields alive / indeterminate / dead; a citizen with no
14
+ * file yields `dead` via ENOENT. Three-valued throughout (`probeSocketLiveness`)
15
+ * — an indeterminate stall is NEVER folded to dead (F3). This is exactly why we
16
+ * cannot reuse the legacy `getLiveSessions` (alive-only listing): folding the
17
+ * hidden indeterminate/dead sockets into "absent" would resurrect the F3 split.
18
+ *
19
+ * This slice fills the LIVENESS axis and, for live sockets, best-effort runtime
20
+ * enrich via the control RPC `get_info` (cwd / model / idle). `SocketProbe`'s
21
+ * enrich fields remain nullable-by-design: a dead/indeterminate socket or a
22
+ * failed enrich is HONEST, not synthetic, and carries `infoError` when known.
23
+ *
24
+ * Three socket-axis hazards are surfaced (slice 4c, Fable 검수), never swallowed:
25
+ * - SYMLINK (P1, security): a `<gid>.sock` that is a symlink can redirect to
26
+ * another session's listener, so gid X would probe ALIVE on Y's socket — a
27
+ * forgery of 동결결정3's correlation authority (the socket filename = the gid).
28
+ * The legacy bridge `getLiveSessions` guarded this (`entry.isSymbolicLink()`);
29
+ * deriving the listing from facts would drop that guard unless we re-assert it
30
+ * here. A symlinked socket is NEVER probed: a citizen owning one is forced to
31
+ * `dead` (→ dormant → resume a fresh process, never SEND to a hijacked
32
+ * listener); a record-less one is quarantined out of the listing entirely.
33
+ * Both surface as `symlinkedGardenIds`.
34
+ * - MALFORMED NAME (P3): a `*.sock` whose stem is not a garden id has no citizen
35
+ * to correlate to and is dropped — but VISIBLY (`malformedNames`), not
36
+ * silently (the legacy path listed any non-empty name; a silent regex drop
37
+ * would violate "no silent drops").
38
+ * - DIR-READ ERROR (P2e②): a missing dir (ENOENT) is the normal fresh-install
39
+ * empty; ANY OTHER readdir failure (EACCES, …) is asymmetric loss of the whole
40
+ * socket axis and is surfaced as `dirError`, not catch-all'd to empty (which
41
+ * would silently vanish every socket-only session). When the dir is untrusted
42
+ * this way, in-domain citizens are NOT probed (a non-ENOENT readdir failure
43
+ * means we cannot confirm the canonical path is not a symlink, and `connect()`
44
+ * would follow one) — they are reported `indeterminate` (liveness unknown),
45
+ * held not stranded: once the dir reads again they route normally (GPi Q2/P1).
46
+ * The provider (slice 4b) folds these three into kind-tagged `EntwurfDiagnostic`s;
47
+ * this lib only reports the raw facts so the import stays one-way (provider →
48
+ * socket-discovery, never back).
49
+ *
50
+ * Deps (dir / readdir / probe) are injectable so the gate drives it without IO.
51
+ */
52
+
53
+ import * as fs from "node:fs/promises";
54
+ import * as os from "node:os";
55
+ import * as path from "node:path";
56
+ import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "./entwurf-control-rpc.ts";
57
+ import type { SocketProbe } from "./entwurf-facts.ts";
58
+ import { SESSION_ID_RE } from "./session-id.js";
59
+ import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
60
+
61
+ /** Canonical control-socket directory; the socket filename IS the gardenId
62
+ * (동결결정3 correlation authority). */
63
+ export const CONTROL_SOCKET_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
64
+ export const SOCKET_SUFFIX = ".sock";
65
+
66
+ // A control-socket filename is a bare garden id. We reuse the repo-wide
67
+ // `SESSION_ID_RE` SSOT (not a local copy): 동결결정3 makes the socket filename the
68
+ // correlation authority, which only holds if the socket axis and the meta-record
69
+ // axis speak the SAME id grammar — a drifted local regex would silently drop a
70
+ // legitimate gid's socket from the scan. A malformed name has no citizen to
71
+ // correlate to and is ignored.
72
+
73
+ export function controlSocketPath(gardenId: string, dir: string = CONTROL_SOCKET_DIR): string {
74
+ return path.join(dir, `${gardenId}${SOCKET_SUFFIX}`);
75
+ }
76
+
77
+ /**
78
+ * Target-specific control-socket inspection for the v2 decider (?2 — lstat-then-
79
+ * connect). The listing scan (`scanSocketProbes`) reads readdir dirents; a dispatch
80
+ * decision needs a fresh, single-target lstat UNDER the per-gid lock so a symlink
81
+ * planted between listing and dispatch cannot forge an alive liveness and hijack a
82
+ * control-socket send (the P1 reopening `probeSocketLiveness`-alone would allow,
83
+ * since it is connect-only and follows symlinks). This helper NEVER connects — it
84
+ * only classifies the canonical path's type so the decider can decide whether a
85
+ * probe is even safe:
86
+ * - `absent` (ENOENT only) → in-domain ⇒ dead (dormant)
87
+ * - `socket-file` → safe to probe (connect) now
88
+ * - `address-conflict` (symlink OR → reject `target-address-conflict` (the gid
89
+ * not-a-socket) resolves to a forged/corrupt address)
90
+ * - `indeterminate` (EACCES/unknown) → not provably absent, never connect, no spawn
91
+ * Every variant carries `socketPath` so the decider plants the SAME path into the
92
+ * plan (no re-derivation — 4c SSOT). `lstatFn` is injectable so the gate drives
93
+ * every branch without a real filesystem; the default is `fs.lstat` (which, unlike
94
+ * connect, does NOT follow the final symlink — that is the whole point).
95
+ */
96
+ export type TargetSocketInspection =
97
+ | { kind: "absent"; socketPath: string }
98
+ | { kind: "socket-file"; socketPath: string }
99
+ | { kind: "address-conflict"; socketPath: string; reason: "symlink" | "not-socket" }
100
+ | { kind: "indeterminate"; socketPath: string; error: string };
101
+
102
+ /**
103
+ * A1 narrow (0.11.0): does this PROBE-FREE single-lstat inspection of a gid's canonical
104
+ * control socket mean a record-LESS pi endpoint is addressable as a socket-only target?
105
+ * TRUE only for a confirmed NON-SYMLINK socket file (`socket-file`); a symlinked /
106
+ * absent / not-socket / `indeterminate` path is conservatively NOT promoted (never trust a
107
+ * symlink, never claim a target on an unprovable lstat). Shared by the v2 production
108
+ * `resolveTarget` so the socket-only acceptance uses the SAME lstat classification the
109
+ * listing/conflict paths use — listing↔dispatch cannot drift on what counts as a real
110
+ * control socket. The decider still does its own under-lock `inspectSocket` probe; this is
111
+ * only the presence hint that promotes `bad-target` → fire-and-forget socket-only pi.
112
+ */
113
+ export function isSocketOnlyPiCandidate(inspection: TargetSocketInspection): boolean {
114
+ return inspection.kind === "socket-file";
115
+ }
116
+
117
+ export interface LstatLike {
118
+ isSymbolicLink(): boolean;
119
+ isSocket(): boolean;
120
+ }
121
+
122
+ /**
123
+ * Inspect the EXACT control-socket path given (no gid re-derivation) and classify it by
124
+ * lstat alone. This is the path-addressed core of the inspection: the 5c-3 spawn-bg watcher
125
+ * observes `plan.expectedSocketPath` and MUST inspect that exact path (its contract forbids
126
+ * re-deriving a path from the gid), so the path-taking form is the SSOT and
127
+ * `inspectTargetControlSocket` is the thin gid→path wrapper over it. `lstatFn` is injectable
128
+ * so the gate drives every branch without a real filesystem; the default is `fs.lstat`
129
+ * (which, unlike connect, does NOT follow the final symlink — that is the whole point: a
130
+ * symlink is caught as an address-conflict and never connected, P1).
131
+ */
132
+ export async function inspectControlSocketPath(
133
+ socketPath: string,
134
+ lstatFn: (p: string) => Promise<LstatLike> = (p) => fs.lstat(p),
135
+ ): Promise<TargetSocketInspection> {
136
+ let st: LstatLike;
137
+ try {
138
+ st = await lstatFn(socketPath);
139
+ } catch (err) {
140
+ const code = (err as NodeJS.ErrnoException).code;
141
+ if (code === "ENOENT") return { kind: "absent", socketPath };
142
+ // EACCES / unknown: not provably absent, so never treat as dead-and-spawn.
143
+ return { kind: "indeterminate", socketPath, error: code ?? "unknown lstat error" };
144
+ }
145
+ // lstat does NOT dereference the final component — a symlink is caught HERE and
146
+ // never connected (P1). A non-socket regular file / dir / fifo at the canonical
147
+ // path is address corruption, not a live socket.
148
+ if (st.isSymbolicLink()) return { kind: "address-conflict", socketPath, reason: "symlink" };
149
+ if (st.isSocket()) return { kind: "socket-file", socketPath };
150
+ return { kind: "address-conflict", socketPath, reason: "not-socket" };
151
+ }
152
+
153
+ /**
154
+ * The gid-addressed inspection (?2 — lstat-then-connect, v2 decider helper): derive the
155
+ * canonical control-socket path for `gardenId` and inspect it. A thin wrapper over
156
+ * `inspectControlSocketPath` so the decider's gid-keyed path and the watcher's exact-path
157
+ * observation share ONE lstat classifier (no drift in the P1 symlink guard).
158
+ */
159
+ export async function inspectTargetControlSocket(
160
+ gardenId: string,
161
+ dir: string = CONTROL_SOCKET_DIR,
162
+ lstatFn: (p: string) => Promise<LstatLike> = (p) => fs.lstat(p),
163
+ ): Promise<TargetSocketInspection> {
164
+ return inspectControlSocketPath(controlSocketPath(gardenId, dir), lstatFn);
165
+ }
166
+
167
+ /**
168
+ * Map a target's socket inspection to a measured `SocketLiveness` (to feed
169
+ * resolveDispatch) or a pre-probe address-conflict signal. `absent` (ENOENT only) is
170
+ * the honest `dead` (the citizen is dormant; its canonical socket is the path a resume
171
+ * will create). `socket-file` is the only case that connects. `address-conflict`
172
+ * (symlink / not-a-socket) and `indeterminate` never connect.
173
+ *
174
+ * Shared SSOT for the v2 decider (5b, decideDispatch) AND the dead-control-send
175
+ * fallback resolver (5c-2b): both must map an inspection the SAME way, or one could
176
+ * route a stalled socket where the other reclaims it — exactly the F3 split this lib
177
+ * exists to prevent. A per-caller copy would drift; this is the single mapper.
178
+ */
179
+ export async function mapInspectionToLiveness(
180
+ inspection: TargetSocketInspection,
181
+ probeSocket: (socketPath: string) => Promise<SocketLiveness>,
182
+ ): Promise<{ liveness: SocketLiveness; socketPath: string } | { addressConflict: true }> {
183
+ switch (inspection.kind) {
184
+ case "absent":
185
+ return { liveness: "dead", socketPath: inspection.socketPath };
186
+ case "socket-file": {
187
+ const liveness = await probeSocket(inspection.socketPath);
188
+ return { liveness, socketPath: inspection.socketPath };
189
+ }
190
+ case "indeterminate":
191
+ return { liveness: "indeterminate", socketPath: inspection.socketPath };
192
+ case "address-conflict":
193
+ return { addressConflict: true };
194
+ }
195
+ }
196
+
197
+ /** One control-socket directory entry, with the single bit the scan needs from
198
+ * the filesystem beyond its name: whether it is a symlink (P1 forgery guard).
199
+ * The real wiring maps `fs.readdir(dir, {withFileTypes:true})` Dirents to this. */
200
+ export interface SocketDirEntry {
201
+ name: string;
202
+ isSymbolicLink: boolean;
203
+ }
204
+
205
+ export interface SocketRuntimeInfo {
206
+ cwd: string | null;
207
+ model: string | null;
208
+ idle: boolean | null;
209
+ }
210
+
211
+ export interface SocketScanDeps {
212
+ dir: string;
213
+ readdir: (dir: string) => Promise<SocketDirEntry[]>;
214
+ probe: (socketPath: string) => Promise<SocketLiveness>;
215
+ /** Best-effort live-socket runtime enrich. Called only when liveness === "alive". */
216
+ getInfo: (socketPath: string) => Promise<SocketRuntimeInfo>;
217
+ }
218
+
219
+ /**
220
+ * The socket axis result. `probes` is the listing input to `resolveFactList`;
221
+ * the other three are surfaced hazards (see the module header) the provider folds
222
+ * into diagnostics — never hidden.
223
+ */
224
+ export interface SocketScanResult {
225
+ probes: SocketProbe[];
226
+ /** gid-shaped `*.sock` symlinks: quarantined from probing (P1). */
227
+ symlinkedGardenIds: string[];
228
+ /** `*.sock` names that are not garden ids: visibly dropped (P3). */
229
+ malformedNames: string[];
230
+ /** non-ENOENT readdir failure: socket axis lost, surfaced not swallowed (P2e②). */
231
+ dirError: string | null;
232
+ }
233
+
234
+ /**
235
+ * Probe the union of (control sockets present in `dir`) ∪ (`piCitizenGardenIds`)
236
+ * and return one `SocketProbe` per gardenId (liveness + live get_info enrich), plus
237
+ * the three surfaced hazards. A missing directory (ENOENT) is the normal empty
238
+ * (`dirError=null`) — the in-domain citizens are still probed (their absent
239
+ * canonical paths read `dead`); any OTHER readdir failure sets `dirError`. A
240
+ * symlinked `*.sock` is never probed (P1): a citizen owning one is forced `dead`,
241
+ * a record-less one is dropped from `probes` entirely. Output sorted by gardenId.
242
+ */
243
+ export async function scanSocketProbes(
244
+ piCitizenGardenIds: readonly string[],
245
+ deps: Partial<SocketScanDeps> = {},
246
+ ): Promise<SocketScanResult> {
247
+ const dir = deps.dir ?? CONTROL_SOCKET_DIR;
248
+ const readdir =
249
+ deps.readdir ??
250
+ (async (d: string): Promise<SocketDirEntry[]> => {
251
+ const dirents = await fs.readdir(d, { withFileTypes: true });
252
+ return dirents.map((e) => ({ name: e.name, isSymbolicLink: e.isSymbolicLink() }));
253
+ });
254
+ const probe = deps.probe ?? ((p: string) => probeSocketLiveness(p));
255
+ // Deterministic gates commonly inject fake readdir/probe over fake paths. In that
256
+ // case, default enrich must stay no-op unless the test explicitly injects getInfo.
257
+ // Real production calls inject neither readdir nor probe, so they get live RPC
258
+ // enrich by default.
259
+ const getInfo =
260
+ deps.getInfo ??
261
+ (deps.readdir || deps.probe
262
+ ? async (): Promise<SocketRuntimeInfo> => ({ cwd: null, model: null, idle: null })
263
+ : getRuntimeInfoOverControlSocket);
264
+
265
+ let entries: SocketDirEntry[] = [];
266
+ let dirError: string | null = null;
267
+ try {
268
+ entries = await readdir(dir);
269
+ } catch (err) {
270
+ // ENOENT = fresh install / no sessions yet = the normal empty. Anything else
271
+ // (EACCES, EIO, …) is real loss of the socket axis — surface it, don't hide it.
272
+ const code = (err as NodeJS.ErrnoException)?.code;
273
+ if (code !== "ENOENT") {
274
+ dirError = err instanceof Error ? err.message : String(err);
275
+ }
276
+ entries = [];
277
+ }
278
+
279
+ const socketGids = new Set<string>();
280
+ const symlinkedGardenIds: string[] = [];
281
+ const malformedNames: string[] = [];
282
+ for (const entry of entries) {
283
+ if (!entry.name.endsWith(SOCKET_SUFFIX)) continue;
284
+ const gid = entry.name.slice(0, -SOCKET_SUFFIX.length);
285
+ if (!SESSION_ID_RE.test(gid)) {
286
+ malformedNames.push(entry.name);
287
+ continue;
288
+ }
289
+ if (entry.isSymbolicLink) {
290
+ // Never trust a symlinked socket: it can point at another session's
291
+ // listener and forge an `alive` for this gid (동결결정3 authority forgery).
292
+ symlinkedGardenIds.push(gid);
293
+ continue;
294
+ }
295
+ socketGids.add(gid);
296
+ }
297
+ const symlinkSet = new Set(symlinkedGardenIds);
298
+
299
+ // A non-ENOENT readdir failure means the dir is untrusted: we could not enumerate
300
+ // it, so we cannot confirm a canonical path is not a symlink. connect() follows
301
+ // symlinks, so probing here would defeat the P1 guard — hold every citizen at
302
+ // `indeterminate` instead (the socket-dir-read-error diagnostic carries the why).
303
+ const dirUntrusted = dirError !== null;
304
+ const allGids = [...new Set([...socketGids, ...piCitizenGardenIds])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
305
+ const probes: SocketProbe[] = [];
306
+ for (const gardenId of allGids) {
307
+ // A citizen whose canonical socket is a symlink is forced `dead` (→ dormant →
308
+ // resume a fresh process) rather than probed through the untrusted link. A
309
+ // record-less symlink gid is not in `allGids` at all (dropped above).
310
+ let liveness: SocketLiveness;
311
+ if (symlinkSet.has(gardenId)) {
312
+ liveness = "dead";
313
+ } else if (dirUntrusted) {
314
+ liveness = "indeterminate";
315
+ } else {
316
+ liveness = await probe(controlSocketPath(gardenId, dir));
317
+ }
318
+ let cwd: string | null = null;
319
+ let model: string | null = null;
320
+ let idle: boolean | null = null;
321
+ let infoError: string | null = null;
322
+ if (liveness === "alive") {
323
+ try {
324
+ const info = await getInfo(controlSocketPath(gardenId, dir));
325
+ cwd = info.cwd;
326
+ model = info.model;
327
+ idle = info.idle;
328
+ } catch (err) {
329
+ infoError = err instanceof Error ? err.message : String(err);
330
+ }
331
+ }
332
+ probes.push({ gardenId, liveness, cwd, model, idle, infoError });
333
+ }
334
+ symlinkedGardenIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
335
+ malformedNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
336
+ return { probes, symlinkedGardenIds, malformedNames, dirError };
337
+ }
338
+
339
+ async function getRuntimeInfoOverControlSocket(socketPath: string): Promise<SocketRuntimeInfo> {
340
+ const info = await fetchControlSocketRuntimeInfo(socketPath, { timeout: 1500 });
341
+ return {
342
+ cwd: info.cwd ?? null,
343
+ model: formatRuntimeModel(info) ?? null,
344
+ idle: info.idle ?? null,
345
+ };
346
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * socket-probe — single source of truth for control-socket liveness.
3
+ *
4
+ * Why this is a shared lib (not a per-file copy): the entwurf-control
5
+ * extension (`pi-extensions/entwurf-control.ts`) AND the MCP bridge
6
+ * (`mcp/entwurf-bridge/src/index.ts`) both probe `~/.pi/entwurf-control/*.sock`.
7
+ * They used to carry independent `isSocketAlive` copies. If only one side
8
+ * learned the three-valued classification the two probes would diverge — the
9
+ * bridge routes a timeout target one way (mailbox fallback) while the
10
+ * extension's GC reclaims the same socket. Both now consume this module so the
11
+ * liveness semantics are identical on every surface. (0.11 Stage 0 step 4
12
+ * plans a probe lib extraction anyway; this is its first slice.)
13
+ *
14
+ * Three-valued, not boolean — the F3 fix. A connect probe can mean three
15
+ * different things, and collapsing them to a boolean is what let
16
+ * `gcStaleSockets` permanently unlink a *live* socket that merely stalled
17
+ * under load (→ every later probe sees it as dormant → live-session resume =
18
+ * identity split). The cure: never destroy a socket we do not understand.
19
+ * - alive → a listener accepted the connection (positive proof)
20
+ * - dead → ECONNREFUSED / ENOENT only (positive proof of absence)
21
+ * - indeterminate → timeout, EACCES, or any other/unknown error
22
+ * (no proof either way — keep the file, hide from listing)
23
+ */
24
+
25
+ import * as net from "node:net";
26
+
27
+ export type SocketLiveness = "alive" | "dead" | "indeterminate";
28
+
29
+ export const DEFAULT_PROBE_TIMEOUT_MS = 300;
30
+
31
+ /**
32
+ * Pure classification of a connect-time error code into liveness. Only the two
33
+ * codes that unambiguously mean "no live listener at this path" are dead:
34
+ * ECONNREFUSED (socket file exists, nothing listening) and ENOENT (no socket
35
+ * file). EVERYTHING else — EACCES, ETIMEDOUT, an undefined code, an unknown
36
+ * code — is indeterminate. The default direction matters: when we don't know,
37
+ * we do not destroy. (동결결정: "ECONNREFUSED/ENOENT만 dead", taken literally.)
38
+ */
39
+ export function classifyConnectError(code: string | undefined): "dead" | "indeterminate" {
40
+ return code === "ECONNREFUSED" || code === "ENOENT" ? "dead" : "indeterminate";
41
+ }
42
+
43
+ /**
44
+ * GC policy: a stale-socket sweep may reclaim ONLY a demonstrably dead socket.
45
+ * An indeterminate probe (timeout / unknown error) must survive the sweep —
46
+ * reclaiming it is exactly the F3 live-session split. Alive obviously survives.
47
+ */
48
+ export function shouldUnlinkOnGc(liveness: SocketLiveness): boolean {
49
+ return liveness === "dead";
50
+ }
51
+
52
+ /**
53
+ * Listing policy: a session appears in the live listing only on a positive
54
+ * connect. Indeterminate is hidden from the listing (preserving the prior
55
+ * boolean listing semantics) but — unlike GC — is NOT unlinked. dead is hidden
56
+ * too. This keeps "what GC reclaims" strictly narrower than "what is listed".
57
+ */
58
+ export function shouldListAsLive(liveness: SocketLiveness): boolean {
59
+ return liveness === "alive";
60
+ }
61
+
62
+ /**
63
+ * Probe a control socket and classify it. Positive connect → alive; a
64
+ * connect-time error is routed through `classifyConnectError`; a connect that
65
+ * neither connects nor errors within `timeoutMs` → indeterminate (the load
66
+ * stall case F3 is about — never coerced to dead).
67
+ */
68
+ export async function probeSocketLiveness(
69
+ socketPath: string,
70
+ opts: { timeoutMs?: number } = {},
71
+ ): Promise<SocketLiveness> {
72
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
73
+ return await new Promise<SocketLiveness>((resolve) => {
74
+ const socket = net.createConnection(socketPath);
75
+ const timer = setTimeout(() => {
76
+ socket.destroy();
77
+ resolve("indeterminate");
78
+ }, timeoutMs);
79
+ const settle = (liveness: SocketLiveness) => {
80
+ clearTimeout(timer);
81
+ socket.removeAllListeners();
82
+ resolve(liveness);
83
+ };
84
+ socket.once("connect", () => {
85
+ socket.end();
86
+ settle("alive");
87
+ });
88
+ socket.once("error", (err: NodeJS.ErrnoException) => {
89
+ settle(classifyConnectError(err.code));
90
+ });
91
+ });
92
+ }