@tekyzinc/gsd-t 3.29.10 → 4.0.12
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 +176 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +27 -3
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +6 -1
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +16 -1
- package/bin/gsd-t-test-data-ledger.cjs +1 -0
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,711 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* GSD-T Headless Auto-Spawn — Detached headless continuation
|
|
5
|
-
*
|
|
6
|
-
* Every GSD-T command spawn routes through `autoSpawnHeadless()` to a
|
|
7
|
-
* detached child process running `gsd-t headless {command} --log`. The
|
|
8
|
-
* interactive session never blocks on the child (`child.unref()`), so the
|
|
9
|
-
* user retains their terminal and can work on unrelated tasks. On child
|
|
10
|
-
* completion, a macOS notification fires (T2). The interactive session
|
|
11
|
-
* surfaces the result via a read-back banner on the next `gsd-t-resume`
|
|
12
|
-
* or `gsd-t-status` call (T4).
|
|
13
|
-
*
|
|
14
|
-
* Zero external dependencies (Node.js built-ins only).
|
|
15
|
-
*
|
|
16
|
-
* Contract: .gsd-t/contracts/headless-default-contract.md v2.0.0
|
|
17
|
-
* - v2.0.0 (M43 D4): channel-separation invariant. Every command spawns.
|
|
18
|
-
* No opt-out flag, no context-meter threshold gating, no `--in-session`
|
|
19
|
-
* escape hatch. `shouldSpawnHeadless` is a constant `() => true`. The
|
|
20
|
-
* `watch` parameter is accepted for caller backward-compat for one
|
|
21
|
-
* version but ignored (deprecation warning emitted once per process).
|
|
22
|
-
* Consumers: every command file that spawns subagents (execute, wave, quick,
|
|
23
|
-
* integrate, debug, scan, verify, complete-milestone, test-sync,
|
|
24
|
-
* scan, gap-analysis, populate, feature, project, partition);
|
|
25
|
-
* the `/gsd` router (Step 2 action-turn handoff).
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
const fs = require("fs");
|
|
29
|
-
const path = require("path");
|
|
30
|
-
const { spawn } = require("child_process");
|
|
31
|
-
const {
|
|
32
|
-
acquireHandoffLock,
|
|
33
|
-
releaseHandoffLock,
|
|
34
|
-
// waitForLockRelease — NOT consumed here. The child-side wait is
|
|
35
|
-
// performed by `commands/gsd-t-resume.md` Step 0 (wired in m36
|
|
36
|
-
// watch-loop Task 4); it calls waitForLockRelease(projectDir,
|
|
37
|
-
// sessionId) before reading the continue-here file.
|
|
38
|
-
} = require("./handoff-lock.cjs");
|
|
39
|
-
|
|
40
|
-
// ── Constants ────────────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
const SESSIONS_DIR_REL = path.join(".gsd-t", "headless-sessions");
|
|
43
|
-
const LOG_DIR_REL = ".gsd-t"; // headless-{id}.log lives directly in .gsd-t
|
|
44
|
-
|
|
45
|
-
// M50 D2 — Whitelist of commands that touch UI / tests. Centralized constant
|
|
46
|
-
// referenced by the spawn-gate (`_isTestingOrUICommand`). When any of these
|
|
47
|
-
// commands is about to spawn for a project where `hasUI(projectDir) === true`
|
|
48
|
-
// and `hasPlaywright(projectDir) === false`, the gate auto-installs Playwright
|
|
49
|
-
// before the spawn proceeds. See playwright-bootstrap-contract.md §3 +
|
|
50
|
-
// m50-integration-points.md "E2E Specs Server Lifecycle".
|
|
51
|
-
const TESTING_OR_UI_COMMANDS = new Set([
|
|
52
|
-
"gsd-t-execute",
|
|
53
|
-
"gsd-t-test-sync",
|
|
54
|
-
"gsd-t-verify",
|
|
55
|
-
"gsd-t-quick",
|
|
56
|
-
"gsd-t-wave",
|
|
57
|
-
"gsd-t-milestone",
|
|
58
|
-
"gsd-t-complete-milestone",
|
|
59
|
-
"gsd-t-debug",
|
|
60
|
-
"gsd-t-integrate",
|
|
61
|
-
]);
|
|
62
|
-
|
|
63
|
-
function _isTestingOrUICommand(command) {
|
|
64
|
-
if (typeof command !== "string" || !command) return false;
|
|
65
|
-
// Accept both raw command names and prefixed variants ("execute" or "gsd-t-execute").
|
|
66
|
-
const normalized = command.startsWith("gsd-t-") ? command : `gsd-t-${command}`;
|
|
67
|
-
return TESTING_OR_UI_COMMANDS.has(normalized);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
module.exports = {
|
|
73
|
-
autoSpawnHeadless,
|
|
74
|
-
makeSessionId,
|
|
75
|
-
writeSessionFile,
|
|
76
|
-
writeContinueHereFile,
|
|
77
|
-
markSessionCompleted,
|
|
78
|
-
// M43 D4 — channel-separation invariant. The helper is retained for
|
|
79
|
-
// backward-compat with any caller that imported it from a v1.x consumer;
|
|
80
|
-
// it now unconditionally returns true. See headless-default-contract
|
|
81
|
-
// v2.0.0 §Invariants.
|
|
82
|
-
shouldSpawnHeadless: () => true,
|
|
83
|
-
// M49 — exported for tests. Synchronous probe; never throws.
|
|
84
|
-
_probeDashboardLazy,
|
|
85
|
-
// M50 D2 — exported for tests. Whitelist + classifier, both synchronous.
|
|
86
|
-
TESTING_OR_UI_COMMANDS,
|
|
87
|
-
_isTestingOrUICommand,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// M43 D4 — one-shot deprecation banner when a caller still passes `watch`
|
|
91
|
-
// (or the never-shipped `inSession`). Module-level flag avoids log spam.
|
|
92
|
-
let _deprecatedWatchWarned = false;
|
|
93
|
-
|
|
94
|
-
// ── autoSpawnHeadless ────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* @param {{
|
|
98
|
-
* command: string,
|
|
99
|
-
* args?: string[],
|
|
100
|
-
* continue_from?: string,
|
|
101
|
-
* projectDir?: string,
|
|
102
|
-
* context?: object,
|
|
103
|
-
* sessionContext?: object,
|
|
104
|
-
* sessionId?: string,
|
|
105
|
-
* watch?: boolean,
|
|
106
|
-
* spawnType?: 'primary' | 'validation',
|
|
107
|
-
* env?: object,
|
|
108
|
-
* workerModel?: string
|
|
109
|
-
* }} opts
|
|
110
|
-
* @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
|
|
111
|
-
*/
|
|
112
|
-
function autoSpawnHeadless(opts) {
|
|
113
|
-
const command = opts.command;
|
|
114
|
-
const args = opts.args || [];
|
|
115
|
-
const continue_from = opts.continue_from || ".";
|
|
116
|
-
const projectDir = opts.projectDir || process.cwd();
|
|
117
|
-
const context = opts.context || opts.sessionContext || null;
|
|
118
|
-
// M44 D9 Step 3 — optional per-call env overrides layered over the inherited
|
|
119
|
-
// process.env. Used by `runDispatch` in gsd-t-parallel.cjs to forward
|
|
120
|
-
// GSD_T_WORKER_TASK_IDS / _WORKER_INDEX / _WORKER_TOTAL to each fan-out
|
|
121
|
-
// child so the child knows which task subset to handle. Purely additive —
|
|
122
|
-
// callers that don't pass `env` get the pre-M44-D9 behavior unchanged.
|
|
123
|
-
const envOverride = (opts.env && typeof opts.env === "object") ? opts.env : null;
|
|
124
|
-
// M43 D4 — `watch` is accepted for caller backward-compat but IGNORED.
|
|
125
|
-
// `inSession` was never shipped; accept+ignore for the same reason.
|
|
126
|
-
// Under headless-default-contract v2.0.0 every spawn goes headless; the
|
|
127
|
-
// only in-session surface is the `/gsd` router dialog channel, which is
|
|
128
|
-
// upstream of this function. One-shot deprecation warning on stderr.
|
|
129
|
-
const legacyWatch = opts.watch === true;
|
|
130
|
-
const legacyInSession = opts.inSession === true;
|
|
131
|
-
if ((legacyWatch || legacyInSession) && !_deprecatedWatchWarned) {
|
|
132
|
-
_deprecatedWatchWarned = true;
|
|
133
|
-
try {
|
|
134
|
-
process.stderr.write(
|
|
135
|
-
"[headless-default] `watch`/`inSession` flag is deprecated under headless-default-contract v2.0.0 — every spawn is headless; caller hint ignored.\n",
|
|
136
|
-
);
|
|
137
|
-
} catch (_) {
|
|
138
|
-
/* best-effort */
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const spawnType = opts.spawnType || "primary";
|
|
142
|
-
|
|
143
|
-
if (!command || typeof command !== "string") {
|
|
144
|
-
throw new Error("autoSpawnHeadless: `command` is required");
|
|
145
|
-
}
|
|
146
|
-
if (spawnType !== "primary" && spawnType !== "validation") {
|
|
147
|
-
throw new Error(
|
|
148
|
-
`autoSpawnHeadless: \`spawnType\` must be 'primary' or 'validation' (got ${JSON.stringify(spawnType)})`,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// M50 D2 — Spawn-time Playwright gate. When the command being spawned is a
|
|
153
|
-
// testing/UI-touching command AND the project has a UI signal AND there's
|
|
154
|
-
// no playwright.config.* yet, auto-install Playwright before the spawn
|
|
155
|
-
// proceeds. On install failure, write a `mode: 'blocked-needs-human'`
|
|
156
|
-
// session-state file (read by gsd-t-resume Step 0 + read-back banner) and
|
|
157
|
-
// exit 4. See playwright-bootstrap-contract.md §3 + §8 and
|
|
158
|
-
// m50-integration-points.md for the full protocol.
|
|
159
|
-
//
|
|
160
|
-
// Hot path: the gate evaluates `_isTestingOrUICommand` (Set lookup) →
|
|
161
|
-
// `hasUI` (depth-bounded fs walk) → `hasPlaywright` (existsSync). When the
|
|
162
|
-
// gate does NOT fire (non-testing command, non-UI project, or already
|
|
163
|
-
// configured), overhead is the cost of three fast filesystem checks.
|
|
164
|
-
// Tests inject `opts._gateInstaller` to substitute a stub installer; tests
|
|
165
|
-
// also inject `opts._gateProbes` to override `hasUI`/`hasPlaywright` for
|
|
166
|
-
// non-fixture-based assertions.
|
|
167
|
-
if (_isTestingOrUICommand(command)) {
|
|
168
|
-
const probes = (opts && opts._gateProbes) || null;
|
|
169
|
-
let projectHasUI;
|
|
170
|
-
let projectHasPlaywright;
|
|
171
|
-
try {
|
|
172
|
-
const ui = probes && probes.hasUI ? probes.hasUI : require("./ui-detection.cjs").hasUI;
|
|
173
|
-
const pw = probes && probes.hasPlaywright ? probes.hasPlaywright : require("./playwright-bootstrap.cjs").hasPlaywright;
|
|
174
|
-
projectHasUI = !!ui(projectDir);
|
|
175
|
-
projectHasPlaywright = !!pw(projectDir);
|
|
176
|
-
} catch (_e) {
|
|
177
|
-
// Probe failure → fail open: skip the gate. A broken probe is worse
|
|
178
|
-
// than a permissive one (we don't want to block every spawn on a stale
|
|
179
|
-
// import path).
|
|
180
|
-
projectHasUI = false;
|
|
181
|
-
projectHasPlaywright = true;
|
|
182
|
-
}
|
|
183
|
-
if (projectHasUI && !projectHasPlaywright) {
|
|
184
|
-
let installer;
|
|
185
|
-
try {
|
|
186
|
-
installer = (opts && opts._gateInstaller) ||
|
|
187
|
-
require("./playwright-bootstrap.cjs").installPlaywrightSync;
|
|
188
|
-
} catch (_e) {
|
|
189
|
-
installer = null;
|
|
190
|
-
}
|
|
191
|
-
if (typeof installer === "function") {
|
|
192
|
-
const result = installer(projectDir);
|
|
193
|
-
if (result && result.ok) {
|
|
194
|
-
try {
|
|
195
|
-
// Concise stdout signal so the user sees the gate fired.
|
|
196
|
-
process.stdout.write(
|
|
197
|
-
`▶ Playwright auto-installed (${path.relative(projectDir, path.join(projectDir, "playwright.config.ts"))})\n`,
|
|
198
|
-
);
|
|
199
|
-
} catch (_) { /* best-effort */ }
|
|
200
|
-
} else {
|
|
201
|
-
// Install failed → mode: 'blocked-needs-human', exit 4.
|
|
202
|
-
const blockedId = makeSessionId(command, new Date());
|
|
203
|
-
try {
|
|
204
|
-
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
205
|
-
writeSessionFile(projectDir, {
|
|
206
|
-
id: blockedId,
|
|
207
|
-
pid: null,
|
|
208
|
-
logPath: null,
|
|
209
|
-
startTimestamp: new Date().toISOString(),
|
|
210
|
-
command,
|
|
211
|
-
args,
|
|
212
|
-
status: "blocked",
|
|
213
|
-
mode: "blocked-needs-human",
|
|
214
|
-
reason: "playwright-install-failed",
|
|
215
|
-
err: (result && result.err) || "unknown",
|
|
216
|
-
hint: (result && result.hint) || null,
|
|
217
|
-
continueFromPath: continue_from,
|
|
218
|
-
surfaced: false,
|
|
219
|
-
});
|
|
220
|
-
} catch (_) { /* best-effort */ }
|
|
221
|
-
try {
|
|
222
|
-
process.stderr.write(
|
|
223
|
-
`[m50-spawn-gate] Playwright install failed (${(result && result.err) || "unknown"}). ` +
|
|
224
|
-
`Run \`gsd-t doctor --install-playwright\` to retry. Spawn aborted.\n`,
|
|
225
|
-
);
|
|
226
|
-
} catch (_) { /* best-effort */ }
|
|
227
|
-
// In test runs we don't want to actually exit the test harness;
|
|
228
|
-
// tests inject `opts._gateExit` to capture the exit instead.
|
|
229
|
-
if (opts && typeof opts._gateExit === "function") {
|
|
230
|
-
opts._gateExit(4);
|
|
231
|
-
return {
|
|
232
|
-
id: blockedId,
|
|
233
|
-
pid: null,
|
|
234
|
-
logPath: null,
|
|
235
|
-
timestamp: new Date().toISOString(),
|
|
236
|
-
mode: "blocked-needs-human",
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
process.exit(4);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const timestamp = new Date().toISOString();
|
|
246
|
-
const id = makeSessionId(command, new Date());
|
|
247
|
-
const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
248
|
-
|
|
249
|
-
ensureDir(path.join(projectDir, LOG_DIR_REL));
|
|
250
|
-
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
251
|
-
|
|
252
|
-
// M49 — Lazy dashboard. Spawns no longer autostart the dashboard. Each
|
|
253
|
-
// spawn would otherwise fork-detach a fresh `gsd-t-dashboard-server.js`
|
|
254
|
-
// (M43 D6-T4); 99% of those banners are never opened so they accumulated
|
|
255
|
-
// to 88+ orphans on the project-scoped port range. The dashboard now only
|
|
256
|
-
// starts when the user explicitly invokes `/gsd-t-visualize`. The banner
|
|
257
|
-
// below remains, but becomes conditional on whether a dashboard is already
|
|
258
|
-
// listening on the project's scoped port.
|
|
259
|
-
//
|
|
260
|
-
// Probe is sync + cheap: read `.gsd-t/.dashboard.pid` and `process.kill(pid, 0)`.
|
|
261
|
-
// Falls back to "no dashboard" on any error — never throws.
|
|
262
|
-
// See .gsd-t/contracts/headless-default-contract.md v2.0.0 §M49 (lazy banner).
|
|
263
|
-
const dashboardInfo = _probeDashboardLazy(projectDir);
|
|
264
|
-
|
|
265
|
-
// M46 follow-up — Date + version banner. Printed before the transcript URL
|
|
266
|
-
// so multi-day-old read-backs are immediately dated. Best-effort.
|
|
267
|
-
try {
|
|
268
|
-
const { dateStamp } = require("../scripts/gsd-t-update-check.js");
|
|
269
|
-
const fsLocal = require("fs");
|
|
270
|
-
const osLocal = require("os");
|
|
271
|
-
const pathLocal = require("path");
|
|
272
|
-
let v = "unknown";
|
|
273
|
-
try {
|
|
274
|
-
v = fsLocal.readFileSync(
|
|
275
|
-
pathLocal.join(osLocal.homedir(), ".claude/.gsd-t-version"), "utf8"
|
|
276
|
-
).trim();
|
|
277
|
-
} catch (_) { /* fall through with "unknown" */ }
|
|
278
|
-
process.stdout.write(`${dateStamp()}GSD-T v${v} — CURRENT\n`);
|
|
279
|
-
} catch (_) {
|
|
280
|
-
/* best-effort — never crash the spawn on banner failure */
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// M49 — Conditional transcript banner. If a dashboard is already running
|
|
284
|
-
// on the project's scoped port, point at the live URL (M43 D6-T3 shape).
|
|
285
|
-
// Otherwise, point at the on-disk log path and hint at /gsd-t-visualize so
|
|
286
|
-
// the user knows how to open the viewer if they want it. The viewer URL
|
|
287
|
-
// is no longer printed for spawns where no listener exists — that is what
|
|
288
|
-
// caused users to assume one was running and accumulated orphans on retry.
|
|
289
|
-
//
|
|
290
|
-
// Text is coordinated with D4 — banner format spec in
|
|
291
|
-
// .gsd-t/contracts/dashboard-server-contract.md v1.3.0 §Banner Format
|
|
292
|
-
// and headless-default-contract.md v2.0.0 §M49.
|
|
293
|
-
try {
|
|
294
|
-
if (dashboardInfo.running && dashboardInfo.port) {
|
|
295
|
-
process.stdout.write(
|
|
296
|
-
`▶ Live transcript: http://127.0.0.1:${dashboardInfo.port}/transcript/${id}\n`,
|
|
297
|
-
);
|
|
298
|
-
} else {
|
|
299
|
-
const relLog = path.relative(projectDir, logPath);
|
|
300
|
-
process.stdout.write(
|
|
301
|
-
`▶ Transcript file: ${relLog}\n (to view live: gsd-t-visualize)\n`,
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
} catch (_) {
|
|
305
|
-
/* best-effort — never crash the spawn on banner failure */
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Handoff-lock gate (m36 gap-fix T2). Only engaged when the caller
|
|
309
|
-
// supplies a `sessionId` — existing callers that do not pass one keep
|
|
310
|
-
// the pre-m36 behavior unchanged. When engaged, the lock is held
|
|
311
|
-
// across writeContinueHereFile + spawn so a child that is already
|
|
312
|
-
// waiting via `waitForLockRelease()` (see commands/gsd-t-resume.md
|
|
313
|
-
// Step 0, wired by m36 watch-loop T4) cannot read a half-written
|
|
314
|
-
// continue-here file. See .gsd-t/contracts/headless-auto-spawn-contract.md
|
|
315
|
-
// v1.0.0 (implementation-detail primitive; no contract bump).
|
|
316
|
-
const lockSessionId = typeof opts.sessionId === "string" && opts.sessionId
|
|
317
|
-
? opts.sessionId
|
|
318
|
-
: null;
|
|
319
|
-
let lockHandle = null;
|
|
320
|
-
if (lockSessionId) {
|
|
321
|
-
lockHandle = acquireHandoffLock(projectDir, lockSessionId);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
let pid = 0;
|
|
325
|
-
try {
|
|
326
|
-
// Open log file descriptor before spawning — child writes directly.
|
|
327
|
-
const logFd = fs.openSync(logPath, "a");
|
|
328
|
-
|
|
329
|
-
// Headless invocation: `node bin/gsd-t.js headless <command> [args] --log`
|
|
330
|
-
// The `gsd-t` CLI entry point is bin/gsd-t.js relative to projectDir.
|
|
331
|
-
const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
|
|
332
|
-
const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
|
|
333
|
-
|
|
334
|
-
// Inject command/phase/trace/model into worker env so event-stream entries
|
|
335
|
-
// (both the writer CLI and the heartbeat PostToolUse hook) are tagged in
|
|
336
|
-
// the child's context (Fix 2, v3.12.12; trace/model/project-dir added
|
|
337
|
-
// v3.12.14 to close the null-telemetry regression).
|
|
338
|
-
//
|
|
339
|
-
// GSD_T_PHASE defaults to "execute" for primary spawns.
|
|
340
|
-
// GSD_T_TRACE_ID / GSD_T_MODEL are inherited from parent env if set;
|
|
341
|
-
// parents (orchestrator, command files) should set them before spawning.
|
|
342
|
-
// GSD_T_PROJECT_DIR gives the writer a stable target when the child cwd
|
|
343
|
-
// drifts (e.g., temp dirs in subagents).
|
|
344
|
-
const workerEnv = Object.assign({}, process.env, {
|
|
345
|
-
GSD_T_COMMAND: command,
|
|
346
|
-
GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
|
|
347
|
-
GSD_T_PROJECT_DIR: process.env.GSD_T_PROJECT_DIR || projectDir,
|
|
348
|
-
});
|
|
349
|
-
if (process.env.GSD_T_TRACE_ID) {
|
|
350
|
-
workerEnv.GSD_T_TRACE_ID = process.env.GSD_T_TRACE_ID;
|
|
351
|
-
}
|
|
352
|
-
if (process.env.GSD_T_MODEL) {
|
|
353
|
-
workerEnv.GSD_T_MODEL = process.env.GSD_T_MODEL;
|
|
354
|
-
}
|
|
355
|
-
workerEnv.GSD_T_AGENT_ID = "headless-" + id;
|
|
356
|
-
if (process.env.GSD_T_AGENT_ID) {
|
|
357
|
-
workerEnv.GSD_T_PARENT_AGENT_ID = process.env.GSD_T_AGENT_ID;
|
|
358
|
-
}
|
|
359
|
-
// M44 D9 Step 3 — caller-supplied env overrides (e.g., fan-out task ids).
|
|
360
|
-
// Applied AFTER the canonical GSD_T_* keys so a caller can override any
|
|
361
|
-
// of them if needed; opaque for everything else.
|
|
362
|
-
if (envOverride) {
|
|
363
|
-
for (const [k, v] of Object.entries(envOverride)) {
|
|
364
|
-
if (v == null) continue;
|
|
365
|
-
workerEnv[k] = String(v);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// Worker-model override (v3.18.18) — let `runDispatch` fan-outs default to
|
|
369
|
-
// Sonnet while the orchestrator stays on whatever the parent runs (often
|
|
370
|
-
// Opus). Moves mechanical fan-out work onto a separate rate-limit bucket,
|
|
371
|
-
// raising the provider concurrency ceiling from ~3 to ~6+ per the
|
|
372
|
-
// Max-subscription concurrency analysis (2026-04-23).
|
|
373
|
-
if (typeof opts.workerModel === "string" && opts.workerModel) {
|
|
374
|
-
workerEnv.ANTHROPIC_MODEL = opts.workerModel;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const child = spawn("node", childArgs, {
|
|
378
|
-
cwd: projectDir,
|
|
379
|
-
detached: true,
|
|
380
|
-
stdio: ["ignore", logFd, logFd],
|
|
381
|
-
env: workerEnv,
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
child.unref();
|
|
385
|
-
fs.closeSync(logFd);
|
|
386
|
-
|
|
387
|
-
pid = child.pid || 0;
|
|
388
|
-
|
|
389
|
-
writeSessionFile(projectDir, {
|
|
390
|
-
id,
|
|
391
|
-
pid,
|
|
392
|
-
logPath: path.relative(projectDir, logPath),
|
|
393
|
-
startTimestamp: timestamp,
|
|
394
|
-
command,
|
|
395
|
-
args,
|
|
396
|
-
status: "running",
|
|
397
|
-
continueFromPath: continue_from,
|
|
398
|
-
surfaced: false,
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
writeContinueHereFile(projectDir, id, context);
|
|
402
|
-
} finally {
|
|
403
|
-
// Release the lock AFTER the child is confirmed started and
|
|
404
|
-
// handoff artifacts are on disk. The finally ensures a spawn
|
|
405
|
-
// failure still frees the slot for a retry.
|
|
406
|
-
if (lockHandle) {
|
|
407
|
-
try {
|
|
408
|
-
releaseHandoffLock(lockHandle);
|
|
409
|
-
} catch (_) {
|
|
410
|
-
/* idempotent best-effort */
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// T2 — install completion watcher. Non-blocking (setImmediate) so the
|
|
416
|
-
// caller's return is not delayed. The watcher uses `child.on('exit')` on
|
|
417
|
-
// a separately-spawned bridge process; here we defer to fs.watchFile for
|
|
418
|
-
// a detached approach that survives even after the parent's `unref()`.
|
|
419
|
-
installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
|
|
420
|
-
|
|
421
|
-
return {
|
|
422
|
-
id,
|
|
423
|
-
pid,
|
|
424
|
-
logPath: path.relative(projectDir, logPath),
|
|
425
|
-
timestamp,
|
|
426
|
-
mode: "headless",
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ── makeSessionId ────────────────────────────────────────────────────────────
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* @param {string} command
|
|
434
|
-
* @param {Date} [now]
|
|
435
|
-
* @returns {string} e.g., "gsd-t-execute-2026-04-15-01-23-45"
|
|
436
|
-
*/
|
|
437
|
-
function makeSessionId(command, now) {
|
|
438
|
-
const d = now || new Date();
|
|
439
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
440
|
-
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
441
|
-
const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
442
|
-
const base = stripGsdtPrefix(command) || command;
|
|
443
|
-
return `gsd-t-${base}-${date}-${time}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ── writeSessionFile ─────────────────────────────────────────────────────────
|
|
447
|
-
|
|
448
|
-
function writeSessionFile(projectDir, session) {
|
|
449
|
-
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${session.id}.json`);
|
|
450
|
-
ensureDir(path.dirname(fp));
|
|
451
|
-
fs.writeFileSync(fp, JSON.stringify(session, null, 2) + "\n");
|
|
452
|
-
return fp;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ── writeContinueHereFile ────────────────────────────────────────────────────
|
|
456
|
-
|
|
457
|
-
function writeContinueHereFile(projectDir, id, context) {
|
|
458
|
-
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}-context.json`);
|
|
459
|
-
const payload = context || buildContextSnapshot(projectDir);
|
|
460
|
-
fs.writeFileSync(fp, JSON.stringify(payload, null, 2) + "\n");
|
|
461
|
-
return fp;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function buildContextSnapshot(projectDir) {
|
|
465
|
-
// Best-effort snapshot of current GSD-T state at handoff time.
|
|
466
|
-
const snap = {
|
|
467
|
-
capturedAt: new Date().toISOString(),
|
|
468
|
-
progress: null,
|
|
469
|
-
currentDomain: null,
|
|
470
|
-
pendingTasks: [],
|
|
471
|
-
lastDecisionLogEntry: null,
|
|
472
|
-
currentWave: null,
|
|
473
|
-
};
|
|
474
|
-
try {
|
|
475
|
-
const progressFp = path.join(projectDir, ".gsd-t", "progress.md");
|
|
476
|
-
if (fs.existsSync(progressFp)) {
|
|
477
|
-
const raw = fs.readFileSync(progressFp, "utf8");
|
|
478
|
-
snap.progress = firstNLines(raw, 20);
|
|
479
|
-
// Pull the last Decision Log entry (last non-empty bullet line).
|
|
480
|
-
const lines = raw.split("\n");
|
|
481
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
482
|
-
const ln = lines[i].trim();
|
|
483
|
-
if (ln.startsWith("- ")) {
|
|
484
|
-
snap.lastDecisionLogEntry = ln;
|
|
485
|
-
break;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
} catch (_) {
|
|
490
|
-
/* best-effort */
|
|
491
|
-
}
|
|
492
|
-
return snap;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function firstNLines(s, n) {
|
|
496
|
-
return s.split("\n").slice(0, n).join("\n");
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// ── markSessionCompleted ─────────────────────────────────────────────────────
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* T2 completion hook — updates the session file in place.
|
|
503
|
-
* @param {string} projectDir
|
|
504
|
-
* @param {string} id
|
|
505
|
-
* @param {{ exitCode: number, endTimestamp?: string }} result
|
|
506
|
-
*/
|
|
507
|
-
function markSessionCompleted(projectDir, id, result) {
|
|
508
|
-
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}.json`);
|
|
509
|
-
if (!fs.existsSync(fp)) return;
|
|
510
|
-
try {
|
|
511
|
-
const s = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
512
|
-
s.status = "completed";
|
|
513
|
-
s.exitCode = result.exitCode;
|
|
514
|
-
s.endTimestamp = result.endTimestamp || new Date().toISOString();
|
|
515
|
-
fs.writeFileSync(fp, JSON.stringify(s, null, 2) + "\n");
|
|
516
|
-
} catch (_) {
|
|
517
|
-
/* ignore */
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// ── Completion watcher (T2) — macOS notification on exit ─────────────────────
|
|
522
|
-
|
|
523
|
-
function installCompletionWatcher(opts) {
|
|
524
|
-
const { projectDir, id, pid, startTimestamp } = opts;
|
|
525
|
-
if (!pid || pid <= 0) return;
|
|
526
|
-
|
|
527
|
-
// Poll-based watcher. We can't hold a reference to the child (it's unref'd
|
|
528
|
-
// and detached), so we poll `process.kill(pid, 0)` which throws if the
|
|
529
|
-
// process is gone. This is cheap and survives across detachment.
|
|
530
|
-
const POLL_MS = 2000;
|
|
531
|
-
const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
|
|
532
|
-
const startMs = Date.now();
|
|
533
|
-
const dtStart = new Date(startTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
|
|
534
|
-
|
|
535
|
-
const timer = setInterval(() => {
|
|
536
|
-
let alive = false;
|
|
537
|
-
try {
|
|
538
|
-
process.kill(pid, 0);
|
|
539
|
-
alive = true;
|
|
540
|
-
} catch (_) {
|
|
541
|
-
alive = false;
|
|
542
|
-
}
|
|
543
|
-
if (!alive) {
|
|
544
|
-
clearInterval(timer);
|
|
545
|
-
// Exit code is unknown from a signal-based probe. Best-effort: read
|
|
546
|
-
// the log's last lines to guess, otherwise default to 0.
|
|
547
|
-
const exitCode = guessExitCodeFromLog(projectDir, id);
|
|
548
|
-
const endTimestamp = new Date().toISOString();
|
|
549
|
-
markSessionCompleted(projectDir, id, {
|
|
550
|
-
exitCode,
|
|
551
|
-
endTimestamp,
|
|
552
|
-
});
|
|
553
|
-
// Append token-log row (Fix 1, v3.12.12)
|
|
554
|
-
const dtEnd = new Date(endTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
|
|
555
|
-
const durationS = Math.round((Date.now() - startMs) / 1000);
|
|
556
|
-
appendTokenLog(projectDir, {
|
|
557
|
-
dtStart,
|
|
558
|
-
dtEnd,
|
|
559
|
-
command: extractCommand(id),
|
|
560
|
-
durationS,
|
|
561
|
-
exitCode,
|
|
562
|
-
});
|
|
563
|
-
fireMacNotification({ id, command: extractCommand(id), startTimestamp });
|
|
564
|
-
} else if (Date.now() - startMs > MAX_WAIT_MS) {
|
|
565
|
-
clearInterval(timer);
|
|
566
|
-
}
|
|
567
|
-
}, POLL_MS);
|
|
568
|
-
|
|
569
|
-
// Let the timer not block the parent's exit.
|
|
570
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function guessExitCodeFromLog(projectDir, id) {
|
|
574
|
-
try {
|
|
575
|
-
const fp = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
576
|
-
if (!fs.existsSync(fp)) return 0;
|
|
577
|
-
const raw = fs.readFileSync(fp, "utf8");
|
|
578
|
-
if (/exit code[: ]+(\d+)/i.test(raw)) {
|
|
579
|
-
const m = raw.match(/exit code[: ]+(\d+)/i);
|
|
580
|
-
return parseInt(m[1], 10);
|
|
581
|
-
}
|
|
582
|
-
return 0;
|
|
583
|
-
} catch (_) {
|
|
584
|
-
return 0;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function extractCommand(id) {
|
|
589
|
-
// id format: gsd-t-{command}-{date}-{time}
|
|
590
|
-
const m = id.match(/^gsd-t-(.+?)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/);
|
|
591
|
-
return m ? m[1] : id;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function fireMacNotification({ id, command }) {
|
|
595
|
-
if (process.platform !== "darwin") return;
|
|
596
|
-
try {
|
|
597
|
-
const { spawn } = require("child_process");
|
|
598
|
-
const msg = `GSD-T headless run complete: ${id}`;
|
|
599
|
-
const script = `display notification "${msg}" with title "GSD-T" subtitle "${command}"`;
|
|
600
|
-
const child = spawn("osascript", ["-e", script], {
|
|
601
|
-
detached: true,
|
|
602
|
-
stdio: "ignore",
|
|
603
|
-
});
|
|
604
|
-
child.unref();
|
|
605
|
-
} catch (_) {
|
|
606
|
-
/* graceful degradation */
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ── Token-Log Writer (Fix 1, v3.12.12) ───────────────────────────────────────
|
|
611
|
-
|
|
612
|
-
const TOKEN_LOG_HEADER =
|
|
613
|
-
"| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
|
|
614
|
-
"|---|---|---|---|---|---|---|---|---|---|\n";
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Append one row to {projectDir}/.gsd-t/token-log.md matching the schema used
|
|
618
|
-
* by interactive command-file observability blocks.
|
|
619
|
-
*
|
|
620
|
-
* @param {string} projectDir
|
|
621
|
-
* @param {{ dtStart: string, dtEnd: string, command: string, durationS: number, exitCode: number }} entry
|
|
622
|
-
*/
|
|
623
|
-
function appendTokenLog(projectDir, entry) {
|
|
624
|
-
try {
|
|
625
|
-
const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
|
|
626
|
-
const note = entry.exitCode === 0 ? "headless spawn: ok" : `headless spawn: exit ${entry.exitCode}`;
|
|
627
|
-
// v3.12.14: prefer env-var model over the old hardcoded "unknown". The
|
|
628
|
-
// parent session should have set GSD_T_MODEL before invoking spawn; fall
|
|
629
|
-
// back to "unknown" if not (graceful).
|
|
630
|
-
const model = process.env.GSD_T_MODEL || "unknown";
|
|
631
|
-
const row =
|
|
632
|
-
`| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | ${model} | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
|
|
633
|
-
if (!fs.existsSync(logPath)) {
|
|
634
|
-
// Create with header
|
|
635
|
-
ensureDir(path.dirname(logPath));
|
|
636
|
-
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${row}`);
|
|
637
|
-
} else {
|
|
638
|
-
// Check if header row exists; if not prepend it (migration for files created before this fix)
|
|
639
|
-
const existing = fs.readFileSync(logPath, "utf8");
|
|
640
|
-
if (!existing.includes("| Datetime-start |")) {
|
|
641
|
-
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${existing}${row}`);
|
|
642
|
-
} else {
|
|
643
|
-
fs.appendFileSync(logPath, row);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
} catch (_) {
|
|
647
|
-
/* best-effort — never halt the completion watcher */
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* M49 — Cheap synchronous probe: is a dashboard listening on this project's
|
|
655
|
-
* scoped port? Strategy: read `.gsd-t/.dashboard.pid`; if the pid is alive
|
|
656
|
-
* (`process.kill(pid, 0)` doesn't throw), assume the dashboard is up and
|
|
657
|
-
* resolve the port via `projectScopedDefaultPort(projectDir)`. No child
|
|
658
|
-
* process forking — this runs on every spawn and must be cheap.
|
|
659
|
-
*
|
|
660
|
-
* Returns `{ running: boolean, port: number|null, pid: number|null }`. Never
|
|
661
|
-
* throws. A return of `{ running: false }` is the safe fallback that drives
|
|
662
|
-
* the file-path-only banner.
|
|
663
|
-
*
|
|
664
|
-
* @param {string} projectDir
|
|
665
|
-
* @returns {{ running: boolean, port: number|null, pid: number|null }}
|
|
666
|
-
*/
|
|
667
|
-
function _probeDashboardLazy(projectDir) {
|
|
668
|
-
const out = { running: false, port: null, pid: null };
|
|
669
|
-
try {
|
|
670
|
-
const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
|
|
671
|
-
if (!fs.existsSync(pidFile)) return out;
|
|
672
|
-
const raw = fs.readFileSync(pidFile, "utf8").trim();
|
|
673
|
-
const pid = parseInt(raw, 10);
|
|
674
|
-
if (!pid || Number.isNaN(pid) || pid <= 0) return out;
|
|
675
|
-
// process.kill(pid, 0) — signal 0 only checks for existence/permission.
|
|
676
|
-
// Throws ESRCH if no such process; EPERM if process exists but owned by
|
|
677
|
-
// someone else. EPERM still implies "alive", so treat both ESRCH-only as
|
|
678
|
-
// dead.
|
|
679
|
-
try {
|
|
680
|
-
process.kill(pid, 0);
|
|
681
|
-
} catch (err) {
|
|
682
|
-
if (err && err.code === "EPERM") {
|
|
683
|
-
// Alive but not owned by us — still a live listener; treat as running.
|
|
684
|
-
} else {
|
|
685
|
-
return out; // ESRCH or unknown — treat as dead.
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
out.pid = pid;
|
|
689
|
-
out.running = true;
|
|
690
|
-
try {
|
|
691
|
-
const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
|
|
692
|
-
out.port = projectScopedDefaultPort(projectDir);
|
|
693
|
-
} catch (_) {
|
|
694
|
-
// Without the port we can't render the URL — fall back to file banner.
|
|
695
|
-
out.running = false;
|
|
696
|
-
out.port = null;
|
|
697
|
-
}
|
|
698
|
-
} catch (_) {
|
|
699
|
-
/* probe is best-effort */
|
|
700
|
-
}
|
|
701
|
-
return out;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function ensureDir(d) {
|
|
705
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function stripGsdtPrefix(command) {
|
|
709
|
-
if (typeof command !== "string") return "";
|
|
710
|
-
return command.replace(/^gsd-t-/, "");
|
|
711
|
-
}
|