@huseynsnmz/pi-suite 0.0.0-stage → 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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch dialog helpers: resolve cwd suggestions, scoped model choices, and thinking options.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** @typedef {"off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"} ThinkingLevel */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} LaunchModelLike
|
|
12
|
+
* @property {string} provider
|
|
13
|
+
* @property {string} id
|
|
14
|
+
* @property {string=} name
|
|
15
|
+
* @property {boolean=} reasoning
|
|
16
|
+
* @property {Partial<Record<ThinkingLevel, string|null>>=} thinkingLevelMap
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} LaunchModelChoice
|
|
21
|
+
* @property {LaunchModelLike} model
|
|
22
|
+
* @property {ThinkingLevel=} thinkingLevel
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} LaunchContext
|
|
27
|
+
* @property {LaunchModelChoice[]} choices
|
|
28
|
+
* @property {LaunchModelLike|null} selectedModel
|
|
29
|
+
* @property {ThinkingLevel} thinking
|
|
30
|
+
* @property {ThinkingLevel[]} thinkingOptions
|
|
31
|
+
* @property {"scoped"|"all"} scopeSource
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const THINKING_LEVELS = /** @type {const} */ (["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} value @returns {value is ThinkingLevel} */
|
|
37
|
+
export function isThinkingLevel(value) {
|
|
38
|
+
return typeof value === "string" && THINKING_LEVELS.includes(/** @type {ThinkingLevel} */ (value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {LaunchModelLike|null|undefined} model */
|
|
42
|
+
export function canonicalModelRef(model) {
|
|
43
|
+
return model ? `${model.provider}/${model.id}` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a stored model reference resolves to a currently-available model.
|
|
48
|
+
* Same rule as the dashboard launch picker: case-insensitive exact "provider/id".
|
|
49
|
+
* A null/empty reference is trivially available (no constraint), and an
|
|
50
|
+
* empty/unavailable model list must not block launches (the caller cannot judge
|
|
51
|
+
* availability, so the conservative answer is allow — issue #90).
|
|
52
|
+
* @param {string|null|undefined} modelRef
|
|
53
|
+
* @param {LaunchModelLike[]|undefined|null} availableModels
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function modelRefAvailable(modelRef, availableModels) {
|
|
57
|
+
const ref = String(modelRef ?? "").trim().toLowerCase();
|
|
58
|
+
if (!ref) return true;
|
|
59
|
+
if (!availableModels || availableModels.length === 0) return true;
|
|
60
|
+
return availableModels.some((model) => `${model.provider}/${model.id}`.toLowerCase() === ref);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {LaunchModelLike|null|undefined} a @param {LaunchModelLike|null|undefined} b */
|
|
64
|
+
export function sameModel(a, b) {
|
|
65
|
+
return Boolean(a && b && a.provider === b.provider && a.id === b.id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {LaunchModelLike|null|undefined} model @returns {ThinkingLevel[]} */
|
|
69
|
+
export function supportedThinkingLevels(model) {
|
|
70
|
+
if (!model?.reasoning) return ["off"];
|
|
71
|
+
const map = model.thinkingLevelMap ?? {};
|
|
72
|
+
const levels = THINKING_LEVELS.filter((level) => map[level] !== null);
|
|
73
|
+
return levels.length ? levels : [...THINKING_LEVELS];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {LaunchModelLike|null|undefined} model
|
|
78
|
+
* @param {ThinkingLevel|undefined|null} requested
|
|
79
|
+
* @param {ThinkingLevel=} fallback
|
|
80
|
+
* @returns {ThinkingLevel}
|
|
81
|
+
*/
|
|
82
|
+
export function clampThinkingLevel(model, requested, fallback = "off") {
|
|
83
|
+
const supported = supportedThinkingLevels(model);
|
|
84
|
+
if (requested && supported.includes(requested)) return requested;
|
|
85
|
+
if (supported.includes(fallback)) return fallback;
|
|
86
|
+
return supported[0] ?? "off";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the model choices for a cwd using pi's scoped-model settings semantics.
|
|
91
|
+
* Falls back to all available models when no scoped models are configured or none resolve.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} cwd
|
|
94
|
+
* @param {LaunchModelLike[]} availableModels
|
|
95
|
+
* @param {LaunchModelLike|null|undefined} preferredModel
|
|
96
|
+
* @param {ThinkingLevel|undefined|null} preferredThinking
|
|
97
|
+
* @returns {LaunchContext}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveLaunchContext(cwd, availableModels, preferredModel, preferredThinking) {
|
|
100
|
+
const settings = SettingsManager.create(cwd);
|
|
101
|
+
const patterns = settings.getEnabledModels();
|
|
102
|
+
const scoped = resolveScopedModels(patterns, availableModels);
|
|
103
|
+
const fallbackChoices = sortModels(availableModels, preferredModel).map((model) => ({ model }));
|
|
104
|
+
const choices = scoped.length ? scoped : fallbackChoices;
|
|
105
|
+
const scopeSource = scoped.length ? "scoped" : "all";
|
|
106
|
+
const defaultThinking = normalizeThinking(settings.getDefaultThinkingLevel()) ?? preferredThinking ?? "off";
|
|
107
|
+
const selectedChoice = preferredModel ? choices.find((choice) => sameModel(choice.model, preferredModel)) : undefined;
|
|
108
|
+
const selectedModel = selectedChoice?.model ?? choices[0]?.model ?? preferredModel ?? null;
|
|
109
|
+
const thinkingBase = selectedChoice?.thinkingLevel ?? preferredThinking ?? defaultThinking;
|
|
110
|
+
const thinking = clampThinkingLevel(selectedModel, thinkingBase, defaultThinking);
|
|
111
|
+
return {
|
|
112
|
+
choices,
|
|
113
|
+
selectedModel,
|
|
114
|
+
thinking,
|
|
115
|
+
thinkingOptions: supportedThinkingLevels(selectedModel),
|
|
116
|
+
scopeSource,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string|undefined} value
|
|
122
|
+
* @returns {ThinkingLevel|undefined}
|
|
123
|
+
*/
|
|
124
|
+
function normalizeThinking(value) {
|
|
125
|
+
return isThinkingLevel(value) ? value : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {LaunchModelLike[]} models @param {LaunchModelLike|null|undefined} current */
|
|
129
|
+
function sortModels(models, current) {
|
|
130
|
+
return [...models].sort((a, b) => {
|
|
131
|
+
const aCurrent = sameModel(a, current);
|
|
132
|
+
const bCurrent = sameModel(b, current);
|
|
133
|
+
if (aCurrent && !bCurrent) return -1;
|
|
134
|
+
if (!aCurrent && bCurrent) return 1;
|
|
135
|
+
const providerCmp = a.provider.localeCompare(b.provider);
|
|
136
|
+
return providerCmp !== 0 ? providerCmp : a.id.localeCompare(b.id);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {string|undefined} patterns @param {LaunchModelLike[]} _available */
|
|
141
|
+
function resolveScopedModels(patterns, _available) {
|
|
142
|
+
const available = Array.isArray(_available) ? _available : [];
|
|
143
|
+
if (!patterns?.length) return [];
|
|
144
|
+
/** @type {LaunchModelChoice[]} */
|
|
145
|
+
const scoped = [];
|
|
146
|
+
for (const rawPattern of patterns) {
|
|
147
|
+
const pattern = String(rawPattern || "").trim();
|
|
148
|
+
if (!pattern) continue;
|
|
149
|
+
if (hasGlob(pattern)) {
|
|
150
|
+
const colonIdx = pattern.lastIndexOf(":");
|
|
151
|
+
let globPattern = pattern;
|
|
152
|
+
/** @type {ThinkingLevel|undefined} */
|
|
153
|
+
let thinkingLevel;
|
|
154
|
+
if (colonIdx !== -1) {
|
|
155
|
+
const suffix = pattern.slice(colonIdx + 1);
|
|
156
|
+
if (isThinkingLevel(suffix)) {
|
|
157
|
+
thinkingLevel = suffix;
|
|
158
|
+
globPattern = pattern.slice(0, colonIdx);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const matches = available.filter((model) => matchGlob(`${model.provider}/${model.id}`, globPattern) || matchGlob(model.id, globPattern));
|
|
162
|
+
for (const model of matches) pushUnique(scoped, { model, thinkingLevel });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const parsed = parseModelPattern(pattern, available);
|
|
166
|
+
if (parsed.model) pushUnique(scoped, { model: parsed.model, thinkingLevel: parsed.thinkingLevel });
|
|
167
|
+
}
|
|
168
|
+
return scoped;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {LaunchModelChoice[]} choices @param {LaunchModelChoice} choice */
|
|
172
|
+
function pushUnique(choices, choice) {
|
|
173
|
+
if (!choices.find((entry) => sameModel(entry.model, choice.model))) choices.push(choice);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @param {string} pattern */
|
|
177
|
+
function hasGlob(pattern) {
|
|
178
|
+
return /[*?[]/.test(pattern);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {string} text @param {string} glob */
|
|
182
|
+
function matchGlob(text, glob) {
|
|
183
|
+
try {
|
|
184
|
+
return new RegExp(`^${globToRegExp(glob)}$`, "i").test(text);
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {string} glob */
|
|
191
|
+
function globToRegExp(glob) {
|
|
192
|
+
let out = "";
|
|
193
|
+
for (let i = 0; i < glob.length; i++) {
|
|
194
|
+
const ch = glob[i];
|
|
195
|
+
if (ch === "*") {
|
|
196
|
+
out += ".*";
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (ch === "?") {
|
|
200
|
+
out += ".";
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (ch === "[") {
|
|
204
|
+
const end = glob.indexOf("]", i + 1);
|
|
205
|
+
if (end > i) {
|
|
206
|
+
out += glob.slice(i, end + 1);
|
|
207
|
+
i = end;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
out += escapeRegExp(ch);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {string} text */
|
|
217
|
+
function escapeRegExp(text) {
|
|
218
|
+
return text.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} id */
|
|
222
|
+
function isAlias(id) {
|
|
223
|
+
if (id.endsWith("-latest")) return true;
|
|
224
|
+
return !/-\d{8}$/.test(id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {string} modelReference @param {LaunchModelLike[]} availableModels */
|
|
228
|
+
function findExactModelReferenceMatch(modelReference, availableModels) {
|
|
229
|
+
const trimmed = modelReference.trim();
|
|
230
|
+
if (!trimmed) return undefined;
|
|
231
|
+
const lower = trimmed.toLowerCase();
|
|
232
|
+
const canonicalMatches = availableModels.filter((model) => `${model.provider}/${model.id}`.toLowerCase() === lower);
|
|
233
|
+
if (canonicalMatches.length === 1) return canonicalMatches[0];
|
|
234
|
+
if (canonicalMatches.length > 1) return undefined;
|
|
235
|
+
const slashIndex = trimmed.indexOf("/");
|
|
236
|
+
if (slashIndex !== -1) {
|
|
237
|
+
const provider = trimmed.slice(0, slashIndex).trim();
|
|
238
|
+
const modelId = trimmed.slice(slashIndex + 1).trim();
|
|
239
|
+
if (provider && modelId) {
|
|
240
|
+
const providerMatches = availableModels.filter((model) => model.provider.toLowerCase() === provider.toLowerCase() && model.id.toLowerCase() === modelId.toLowerCase());
|
|
241
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
242
|
+
if (providerMatches.length > 1) return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const idMatches = availableModels.filter((model) => model.id.toLowerCase() === lower);
|
|
246
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {string} modelPattern @param {LaunchModelLike[]} availableModels */
|
|
250
|
+
function tryMatchModel(modelPattern, availableModels) {
|
|
251
|
+
const exact = findExactModelReferenceMatch(modelPattern, availableModels);
|
|
252
|
+
if (exact) return exact;
|
|
253
|
+
const lower = modelPattern.toLowerCase();
|
|
254
|
+
const matches = availableModels.filter((model) => model.id.toLowerCase().includes(lower) || model.name?.toLowerCase().includes(lower));
|
|
255
|
+
if (!matches.length) return undefined;
|
|
256
|
+
const aliases = matches.filter((model) => isAlias(model.id));
|
|
257
|
+
const dated = matches.filter((model) => !isAlias(model.id));
|
|
258
|
+
if (aliases.length) return aliases.sort((a, b) => b.id.localeCompare(a.id))[0];
|
|
259
|
+
return dated.sort((a, b) => b.id.localeCompare(a.id))[0];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @param {string} pattern @param {LaunchModelLike[]} availableModels */
|
|
263
|
+
function parseModelPattern(pattern, availableModels) {
|
|
264
|
+
const exact = tryMatchModel(pattern, availableModels);
|
|
265
|
+
if (exact) return { model: exact, thinkingLevel: undefined };
|
|
266
|
+
const lastColon = pattern.lastIndexOf(":");
|
|
267
|
+
if (lastColon === -1) return { model: undefined, thinkingLevel: undefined };
|
|
268
|
+
const prefix = pattern.slice(0, lastColon);
|
|
269
|
+
const suffix = pattern.slice(lastColon + 1);
|
|
270
|
+
if (isThinkingLevel(suffix)) {
|
|
271
|
+
const result = parseModelPattern(prefix, availableModels);
|
|
272
|
+
if (result.model) return { model: result.model, thinkingLevel: suffix };
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
return parseModelPattern(prefix, availableModels);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @param {string} input */
|
|
279
|
+
function expandHome(input) {
|
|
280
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
281
|
+
if (!home) return input;
|
|
282
|
+
if (input === "~") return home;
|
|
283
|
+
if (input.startsWith(`~${path.sep}`)) return path.join(home, input.slice(2));
|
|
284
|
+
return input;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {string} value @param {string} baseCwd */
|
|
288
|
+
export function resolveDirectoryValue(value, baseCwd) {
|
|
289
|
+
const raw = String(value || "").trim();
|
|
290
|
+
if (!raw) return existsDir(baseCwd) ? path.resolve(baseCwd) : null;
|
|
291
|
+
const expanded = expandHome(raw);
|
|
292
|
+
const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseCwd, expanded);
|
|
293
|
+
return existsDir(resolved) ? resolved : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {string} query
|
|
298
|
+
* @param {string} baseCwd
|
|
299
|
+
* @param {number=} limit
|
|
300
|
+
* @returns {string[]}
|
|
301
|
+
*/
|
|
302
|
+
export function listDirectorySuggestions(query, baseCwd, limit = 8) {
|
|
303
|
+
const raw = String(query || "").trim();
|
|
304
|
+
const expanded = expandHome(raw);
|
|
305
|
+
const resolved = raw ? (path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseCwd, expanded)) : path.resolve(baseCwd);
|
|
306
|
+
let searchDir = resolved;
|
|
307
|
+
let fragment = "";
|
|
308
|
+
if (!existsDir(resolved) || !raw.endsWith(path.sep)) {
|
|
309
|
+
searchDir = existsDir(resolved) ? resolved : path.dirname(resolved);
|
|
310
|
+
fragment = existsDir(resolved) ? "" : path.basename(resolved);
|
|
311
|
+
}
|
|
312
|
+
/** @type {string[]} */
|
|
313
|
+
const out = [];
|
|
314
|
+
if (existsDir(resolved)) out.push(path.resolve(resolved));
|
|
315
|
+
if (existsDir(searchDir)) {
|
|
316
|
+
const lowerFragment = fragment.toLowerCase();
|
|
317
|
+
const children = readdirSync(searchDir, { withFileTypes: true })
|
|
318
|
+
.filter((entry) => entry.isDirectory())
|
|
319
|
+
.map((entry) => entry.name)
|
|
320
|
+
.filter((name) => !lowerFragment || name.toLowerCase().includes(lowerFragment))
|
|
321
|
+
.sort((a, b) => a.localeCompare(b));
|
|
322
|
+
for (const name of children) out.push(path.join(searchDir, name));
|
|
323
|
+
}
|
|
324
|
+
return Array.from(new Set(out)).slice(0, Math.max(1, limit));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @param {string} dir */
|
|
328
|
+
function existsDir(dir) {
|
|
329
|
+
try {
|
|
330
|
+
return existsSync(dir) && statSync(dir).isDirectory();
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Keep only candidates whose path is an existing directory.
|
|
338
|
+
* @param {CwdCandidate[]} candidates
|
|
339
|
+
* @returns {CwdCandidate[]}
|
|
340
|
+
*/
|
|
341
|
+
export function existingCwdCandidates(candidates) {
|
|
342
|
+
return candidates.filter((entry) => existsDir(entry.path));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @typedef {Object} CwdCandidate
|
|
347
|
+
* @property {string} path
|
|
348
|
+
* @property {number} count
|
|
349
|
+
*/
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Filter ranked cwd candidates by case-insensitive substring match anywhere
|
|
353
|
+
* in the path (empty query keeps the full ranked list).
|
|
354
|
+
* @param {CwdCandidate[]} candidates
|
|
355
|
+
* @param {string} query
|
|
356
|
+
* @returns {CwdCandidate[]}
|
|
357
|
+
*/
|
|
358
|
+
export function filterCwdCandidates(candidates, query) {
|
|
359
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
360
|
+
if (!q) return candidates;
|
|
361
|
+
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether two path strings resolve to the same directory. Blank inputs never
|
|
366
|
+
* match (path.resolve("") would silently equal process.cwd()).
|
|
367
|
+
* @param {string} a
|
|
368
|
+
* @param {string} b
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function sameResolvedDir(a, b) {
|
|
372
|
+
const na = String(a ?? "").trim();
|
|
373
|
+
const nb = String(b ?? "").trim();
|
|
374
|
+
if (!na || !nb) return false;
|
|
375
|
+
return path.resolve(expandHome(na)) === path.resolve(expandHome(nb));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Browse-mode Tab completion for the cwd picker. Completes to the highlighted
|
|
380
|
+
* suggestion with a trailing separator (so the next recompute lists that
|
|
381
|
+
* directory's children); when the highlight already equals the current query
|
|
382
|
+
* (no progress), advances cyclically so repeated Tab drills downward. A lone
|
|
383
|
+
* self suggestion stays put, so drilling stops naturally at leaf directories.
|
|
384
|
+
* @param {string} query
|
|
385
|
+
* @param {string[]} suggestions
|
|
386
|
+
* @param {number} index
|
|
387
|
+
* @returns {{query: string, completed: string, usedIndex: number} | null}
|
|
388
|
+
*/
|
|
389
|
+
export function browseTabCompletion(query, suggestions, index) {
|
|
390
|
+
if (!suggestions || suggestions.length === 0) return null;
|
|
391
|
+
const len = suggestions.length;
|
|
392
|
+
let usedIndex = ((index % len) + len) % len;
|
|
393
|
+
let completed = suggestions[usedIndex];
|
|
394
|
+
if (len > 1 && sameResolvedDir(completed, query)) {
|
|
395
|
+
usedIndex = (usedIndex + 1) % len;
|
|
396
|
+
completed = suggestions[usedIndex];
|
|
397
|
+
}
|
|
398
|
+
return { query: completed + path.sep, completed, usedIndex };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
403
|
+
* is empty or matches ranked candidates, filesystem browse otherwise.
|
|
404
|
+
* @param {string} query
|
|
405
|
+
* @param {CwdCandidate[]} ranked
|
|
406
|
+
* @param {string} baseCwd
|
|
407
|
+
* @returns {{mode: "favorites"|"browse", suggestions: string[]}}
|
|
408
|
+
*/
|
|
409
|
+
export function nextCwdPickerState(query, ranked, baseCwd) {
|
|
410
|
+
if (!ranked || ranked.length === 0) {
|
|
411
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
412
|
+
}
|
|
413
|
+
const matches = filterCwdCandidates(ranked, query);
|
|
414
|
+
if (String(query ?? "").trim() === "" || matches.length > 0) {
|
|
415
|
+
return { mode: "favorites", suggestions: matches.map((entry) => entry.path) };
|
|
416
|
+
}
|
|
417
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
418
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch a detached job-runner process for one run.
|
|
3
|
+
*
|
|
4
|
+
* The runner is a plain `.mjs` spawned with `node`, fully detached (its own process
|
|
5
|
+
* group, stdio ignored) so it survives the parent Pi reloading or exiting. The parent
|
|
6
|
+
* records the runner pid in `pid.json` and watches the store files the runner writes.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { atomicWriteJson } from "./atomic.mjs";
|
|
11
|
+
import { resolveNode } from "./invocation.mjs";
|
|
12
|
+
import * as P from "./paths.mjs";
|
|
13
|
+
import { writePid } from "./store.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./types.mjs").RunConfig} RunConfig */
|
|
16
|
+
/** @typedef {import("./types.mjs").HostConfig} HostConfig */
|
|
17
|
+
/** @typedef {import("./types.mjs").TitleConfig} TitleConfig */
|
|
18
|
+
/** @typedef {import("./types.mjs").AutoStateConfig} AutoStateConfig */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Spawn a fully detached runner child with async spawn failures made harmless.
|
|
22
|
+
*
|
|
23
|
+
* A spawn that fails to start (e.g. a transient ENOENT on the node binary,
|
|
24
|
+
* issue #86) reports asynchronously via the 'error' event; without a listener
|
|
25
|
+
* the EventEmitter rethrows it as an uncaughtException and takes down the
|
|
26
|
+
* whole host Pi process. Callers already handle the failure gracefully via
|
|
27
|
+
* the pid == null branch (state "failed").
|
|
28
|
+
* @param {string} command
|
|
29
|
+
* @param {string[]} args
|
|
30
|
+
* @param {string} cwd
|
|
31
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
32
|
+
*/
|
|
33
|
+
function spawnDetached(command, args, cwd) {
|
|
34
|
+
const child = spawn(command, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
detached: true,
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
env: process.env,
|
|
39
|
+
// Windows: detached children get their own console window unless
|
|
40
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
41
|
+
windowsHide: true,
|
|
42
|
+
});
|
|
43
|
+
child.on("error", () => {});
|
|
44
|
+
child.unref();
|
|
45
|
+
return child;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} root
|
|
50
|
+
* @param {RunConfig} config
|
|
51
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
52
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function launchRun(root, config, opts) {
|
|
55
|
+
const runDir = P.runDir(root, config.viewId, config.runId);
|
|
56
|
+
const configPath = `${runDir}/config.json`;
|
|
57
|
+
atomicWriteJson(configPath, config);
|
|
58
|
+
|
|
59
|
+
const node = opts.node ?? resolveNode();
|
|
60
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
61
|
+
|
|
62
|
+
const pid = child.pid ?? null;
|
|
63
|
+
// Record the *runner/monitor* pid for liveness polling (the worker pid is tracked
|
|
64
|
+
// inside status.json by the runner itself).
|
|
65
|
+
writePid(root, config.viewId, config.runId, pid);
|
|
66
|
+
return { pid, configPath };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Launch a detached PTY host for a view. The host owns a long-lived child Pi and
|
|
71
|
+
* exposes a JSONL control socket for attach/input/resize/terminate.
|
|
72
|
+
* @param {string} root
|
|
73
|
+
* @param {HostConfig} config
|
|
74
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
75
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
76
|
+
*/
|
|
77
|
+
export function launchHost(root, config, opts) {
|
|
78
|
+
// Instance-specific config path wins when present (issue #70: concurrent
|
|
79
|
+
// launches must not share the fixed host-config.json); legacy callers keep it.
|
|
80
|
+
const configPath = config.configPath ?? P.hostConfigPath(root, config.viewId);
|
|
81
|
+
atomicWriteJson(configPath, config);
|
|
82
|
+
|
|
83
|
+
const node = opts.node ?? resolveNode();
|
|
84
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
85
|
+
|
|
86
|
+
return { pid: child.pid ?? null, configPath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Launch a detached title runner for a view. Best-effort only: it may update `meta.json`
|
|
91
|
+
* later with a short GPT-generated name derived from the initial task prompt.
|
|
92
|
+
* @param {string} root
|
|
93
|
+
* @param {TitleConfig} config
|
|
94
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
95
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
96
|
+
*/
|
|
97
|
+
export function launchTitle(root, config, opts) {
|
|
98
|
+
const configPath = P.titleConfigPath(root, config.viewId);
|
|
99
|
+
atomicWriteJson(configPath, config);
|
|
100
|
+
|
|
101
|
+
const node = opts.node ?? resolveNode();
|
|
102
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
103
|
+
|
|
104
|
+
return { pid: child.pid ?? null, configPath };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Launch a detached auto-state classifier for a view. Best-effort: it may update
|
|
109
|
+
* state.json later with a model-refined terminal bucket.
|
|
110
|
+
* @param {string} root
|
|
111
|
+
* @param {AutoStateConfig} config
|
|
112
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
113
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
114
|
+
*/
|
|
115
|
+
export function launchAutoState(root, config, opts) {
|
|
116
|
+
const configPath = P.autoStateConfigPath(root, config.viewId);
|
|
117
|
+
atomicWriteJson(configPath, config);
|
|
118
|
+
|
|
119
|
+
const node = opts.node ?? resolveNode();
|
|
120
|
+
const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
|
|
121
|
+
|
|
122
|
+
return { pid: child.pid ?? null, configPath };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compute the detached coordinator spawn plan (issue #241): an absolute root
|
|
127
|
+
* argv and a cwd OUTSIDE the root (the root's parent; at a drive/filesystem
|
|
128
|
+
* root the parent is the root itself). The coordinator derives all paths from
|
|
129
|
+
* the root argument and never uses cwd, so keeping cwd off the root releases
|
|
130
|
+
* the win32 directory handle that made throwaway roots undeletable.
|
|
131
|
+
* @param {string} root
|
|
132
|
+
* @returns {{ root: string, cwd: string }}
|
|
133
|
+
*/
|
|
134
|
+
export function coordinatorSpawnPlan(root) {
|
|
135
|
+
const absoluteRoot = resolve(root);
|
|
136
|
+
return { root: absoluteRoot, cwd: dirname(absoluteRoot) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Launch the detached view-state coordinator for a board root (issue #91, spec
|
|
141
|
+
* D3). No config file: the coordinator takes the root as its only argument.
|
|
142
|
+
* Idempotent by lease — a second instance loses the coordinator lease and
|
|
143
|
+
* exits silently, so callers may spawn freely on probe failure.
|
|
144
|
+
* The child's cwd is the root's PARENT (issue #241): the coordinator derives
|
|
145
|
+
* paths from argv and cwd must not pin the root on win32. At a drive/filesystem
|
|
146
|
+
* root (win32 `C:\`, POSIX `/`) the parent is the root itself — see
|
|
147
|
+
* `coordinatorSpawnPlan`.
|
|
148
|
+
* @param {string} root
|
|
149
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
150
|
+
* @returns {{ pid: number|null }}
|
|
151
|
+
*/
|
|
152
|
+
export function launchCoordinator(root, opts) {
|
|
153
|
+
const node = opts.node ?? resolveNode();
|
|
154
|
+
const plan = coordinatorSpawnPlan(root);
|
|
155
|
+
const child = spawnDetached(node, [opts.runnerScript, plan.root], plan.cwd);
|
|
156
|
+
return { pid: child.pid ?? null };
|
|
157
|
+
}
|