gsd-pi 2.33.0-dev.69bff0f → 2.33.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/README.md +18 -13
- package/dist/resources/extensions/gsd/auto-supervisor.ts +5 -10
- package/dist/resources/extensions/gsd/auto-worktree.ts +1 -135
- package/dist/resources/extensions/gsd/commands.ts +2 -14
- package/dist/resources/extensions/gsd/session-lock.ts +16 -80
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
- package/dist/resources/extensions/mcporter/extension-manifest.json +12 -0
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-supervisor.ts +5 -10
- package/src/resources/extensions/gsd/auto-worktree.ts +1 -135
- package/src/resources/extensions/gsd/commands.ts +2 -14
- package/src/resources/extensions/gsd/session-lock.ts +16 -80
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
- package/src/resources/extensions/mcporter/extension-manifest.json +12 -0
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
package/README.md
CHANGED
|
@@ -24,19 +24,24 @@ One command. Walk away. Come back to a built project with clean git history.
|
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
## What's New in v2.
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
-
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
27
|
+
## What's New in v2.32
|
|
28
|
+
|
|
29
|
+
- **Simplified pipeline** — research merged into planning, mechanical completion (ADR-003)
|
|
30
|
+
- **Always-on health widget** — 🟢🟡🔴 traffic-light indicator in the progress widget and visualizer health tab
|
|
31
|
+
- **Environment health checks** — progress scoring and status integration for auto-mode
|
|
32
|
+
- **Extension registry** — user-managed enable/disable for bundled and custom extensions
|
|
33
|
+
- **Built-in skill authoring** — create and distribute custom skills from within GSD
|
|
34
|
+
- **Workflow templates** — right-sized workflows for every task type (research, plan, execute, complete)
|
|
35
|
+
- **AWS Bedrock auth** — automatic credential refresh via the new `aws-auth` extension
|
|
36
|
+
- **`-w` / `--worktree` CLI flag** — launch isolated worktree sessions from the command line
|
|
37
|
+
- **Native MCP client** — replaced MCPorter with a built-in MCP client for better reliability
|
|
38
|
+
- **External state directory** — `.gsd/` now lives in `~/.gsd/projects/` with a symlink (ADR-002)
|
|
39
|
+
- **Model health indicator** — live health status based on error trends and consecutive failures
|
|
40
|
+
- **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back after completion
|
|
41
|
+
- **Windows EPERM fallback** — migration rename uses copy+delete when NTFS blocks rename
|
|
42
|
+
- **Worktree identity fix** — stable project hash across worktrees and main repo
|
|
43
|
+
- **Crash recovery guidance** — actionable next-step messages based on what was interrupted
|
|
44
|
+
- **UAT verdict gating** — non-PASS verdicts now block slice progression instead of being ignored
|
|
40
45
|
|
|
41
46
|
See the full [Changelog](./CHANGELOG.md) for details.
|
|
42
47
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-mode Supervisor —
|
|
2
|
+
* Auto-mode Supervisor — SIGTERM handling and working-tree activity detection.
|
|
3
3
|
*
|
|
4
4
|
* Pure functions — no module-level globals or AutoContext dependency.
|
|
5
5
|
*/
|
|
@@ -8,10 +8,10 @@ import { clearLock } from "./crash-recovery.js";
|
|
|
8
8
|
import { releaseSessionLock } from "./session-lock.js";
|
|
9
9
|
import { nativeHasChanges } from "./native-git-bridge.js";
|
|
10
10
|
|
|
11
|
-
// ───
|
|
11
|
+
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Register SIGTERM
|
|
14
|
+
* Register a SIGTERM handler that clears the lock file and exits cleanly.
|
|
15
15
|
* Captures the active base path at registration time so the handler
|
|
16
16
|
* always references the correct path even if the module variable changes.
|
|
17
17
|
* Removes any previously registered handler before installing the new one.
|
|
@@ -22,25 +22,20 @@ export function registerSigtermHandler(
|
|
|
22
22
|
currentBasePath: string,
|
|
23
23
|
previousHandler: (() => void) | null,
|
|
24
24
|
): () => void {
|
|
25
|
-
if (previousHandler)
|
|
26
|
-
process.off("SIGTERM", previousHandler);
|
|
27
|
-
process.off("SIGINT", previousHandler);
|
|
28
|
-
}
|
|
25
|
+
if (previousHandler) process.off("SIGTERM", previousHandler);
|
|
29
26
|
const handler = () => {
|
|
30
27
|
releaseSessionLock(currentBasePath);
|
|
31
28
|
clearLock(currentBasePath);
|
|
32
29
|
process.exit(0);
|
|
33
30
|
};
|
|
34
31
|
process.on("SIGTERM", handler);
|
|
35
|
-
process.on("SIGINT", handler);
|
|
36
32
|
return handler;
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
/** Deregister
|
|
35
|
+
/** Deregister the SIGTERM handler (called on stop/pause). */
|
|
40
36
|
export function deregisterSigtermHandler(handler: (() => void) | null): void {
|
|
41
37
|
if (handler) {
|
|
42
38
|
process.off("SIGTERM", handler);
|
|
43
|
-
process.off("SIGINT", handler);
|
|
44
39
|
}
|
|
45
40
|
}
|
|
46
41
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* manages create, enter, detect, and teardown for auto-mode worktrees.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
|
|
10
10
|
import { isAbsolute, join, sep } from "node:path";
|
|
11
11
|
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
|
|
12
12
|
import { execSync, execFileSync } from "node:child_process";
|
|
@@ -45,122 +45,6 @@ import { getErrorMessage } from "./error-utils.js";
|
|
|
45
45
|
/** Original project root before chdir into auto-worktree. */
|
|
46
46
|
let originalBase: string | null = null;
|
|
47
47
|
|
|
48
|
-
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Sync .gsd/ state from the main repo into the worktree.
|
|
52
|
-
*
|
|
53
|
-
* When .gsd/ is a symlink to the external state directory, both the main
|
|
54
|
-
* repo and worktree share the same directory — no sync needed.
|
|
55
|
-
*
|
|
56
|
-
* When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
|
|
57
|
-
* the worktree has its own copy that may be stale. This function copies
|
|
58
|
-
* missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
|
|
59
|
-
* PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
|
|
60
|
-
*
|
|
61
|
-
* Only adds missing content — never overwrites existing files in the worktree
|
|
62
|
-
* (the worktree's execution state is authoritative for in-progress work).
|
|
63
|
-
*/
|
|
64
|
-
export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
|
|
65
|
-
const mainGsd = gsdRoot(mainBasePath);
|
|
66
|
-
const wtGsd = gsdRoot(worktreePath_);
|
|
67
|
-
const synced: string[] = [];
|
|
68
|
-
|
|
69
|
-
// If both resolve to the same directory (symlink), no sync needed
|
|
70
|
-
try {
|
|
71
|
-
const mainResolved = realpathSync(mainGsd);
|
|
72
|
-
const wtResolved = realpathSync(wtGsd);
|
|
73
|
-
if (mainResolved === wtResolved) return { synced };
|
|
74
|
-
} catch {
|
|
75
|
-
// Can't resolve — proceed with sync as a safety measure
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
|
|
79
|
-
|
|
80
|
-
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
|
|
81
|
-
const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
|
|
82
|
-
for (const f of rootFiles) {
|
|
83
|
-
const src = join(mainGsd, f);
|
|
84
|
-
const dst = join(wtGsd, f);
|
|
85
|
-
if (existsSync(src) && !existsSync(dst)) {
|
|
86
|
-
try {
|
|
87
|
-
cpSync(src, dst);
|
|
88
|
-
synced.push(f);
|
|
89
|
-
} catch { /* non-fatal */ }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Sync milestones: copy entire milestone directories that are missing
|
|
94
|
-
const mainMilestonesDir = join(mainGsd, "milestones");
|
|
95
|
-
const wtMilestonesDir = join(wtGsd, "milestones");
|
|
96
|
-
if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
|
|
97
|
-
try {
|
|
98
|
-
const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
|
|
99
|
-
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
|
|
100
|
-
.map(d => d.name);
|
|
101
|
-
|
|
102
|
-
for (const mid of mainMilestones) {
|
|
103
|
-
const srcDir = join(mainMilestonesDir, mid);
|
|
104
|
-
const dstDir = join(wtMilestonesDir, mid);
|
|
105
|
-
|
|
106
|
-
if (!existsSync(dstDir)) {
|
|
107
|
-
// Entire milestone missing from worktree — copy it
|
|
108
|
-
try {
|
|
109
|
-
cpSync(srcDir, dstDir, { recursive: true });
|
|
110
|
-
synced.push(`milestones/${mid}/`);
|
|
111
|
-
} catch { /* non-fatal */ }
|
|
112
|
-
} else {
|
|
113
|
-
// Milestone directory exists but may be missing files (stale snapshot).
|
|
114
|
-
// Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
|
|
115
|
-
try {
|
|
116
|
-
const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
|
|
117
|
-
for (const f of srcFiles) {
|
|
118
|
-
const srcFile = join(srcDir, f);
|
|
119
|
-
const dstFile = join(dstDir, f);
|
|
120
|
-
if (!existsSync(dstFile)) {
|
|
121
|
-
try {
|
|
122
|
-
const srcStat = lstatSyncFn(srcFile);
|
|
123
|
-
if (srcStat.isFile()) {
|
|
124
|
-
cpSync(srcFile, dstFile);
|
|
125
|
-
synced.push(`milestones/${mid}/${f}`);
|
|
126
|
-
}
|
|
127
|
-
} catch { /* non-fatal */ }
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Sync slices directory if it exists in main but not in worktree
|
|
132
|
-
const srcSlicesDir = join(srcDir, "slices");
|
|
133
|
-
const dstSlicesDir = join(dstDir, "slices");
|
|
134
|
-
if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
|
|
135
|
-
try {
|
|
136
|
-
cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
|
|
137
|
-
synced.push(`milestones/${mid}/slices/`);
|
|
138
|
-
} catch { /* non-fatal */ }
|
|
139
|
-
} else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
|
|
140
|
-
// Both exist — sync missing slice directories
|
|
141
|
-
const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
|
|
142
|
-
.filter(d => d.isDirectory())
|
|
143
|
-
.map(d => d.name);
|
|
144
|
-
for (const sid of srcSlices) {
|
|
145
|
-
const srcSlice = join(srcSlicesDir, sid);
|
|
146
|
-
const dstSlice = join(dstSlicesDir, sid);
|
|
147
|
-
if (!existsSync(dstSlice)) {
|
|
148
|
-
try {
|
|
149
|
-
cpSync(srcSlice, dstSlice, { recursive: true });
|
|
150
|
-
synced.push(`milestones/${mid}/slices/${sid}/`);
|
|
151
|
-
} catch { /* non-fatal */ }
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch { /* non-fatal */ }
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
} catch { /* non-fatal */ }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return { synced };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
48
|
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
|
|
165
49
|
|
|
166
50
|
/**
|
|
@@ -241,12 +125,6 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
|
|
|
241
125
|
// Ensure worktree shares external state via symlink
|
|
242
126
|
ensureGsdSymlink(info.path);
|
|
243
127
|
|
|
244
|
-
// Sync .gsd/ state from main repo into the worktree (#1311).
|
|
245
|
-
// Even with the symlink, the worktree may have stale git-tracked files
|
|
246
|
-
// if .gsd/ is not gitignored. And on fresh create, the milestone files
|
|
247
|
-
// created on main since the branch point won't be in the worktree.
|
|
248
|
-
syncGsdStateToWorktree(basePath, info.path);
|
|
249
|
-
|
|
250
128
|
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
|
|
251
129
|
const hookError = runWorktreePostCreateHook(basePath, info.path);
|
|
252
130
|
if (hookError) {
|
|
@@ -389,18 +267,6 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
|
|
|
389
267
|
throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
|
|
390
268
|
}
|
|
391
269
|
|
|
392
|
-
// Ensure worktree shares external state via symlink (#1311).
|
|
393
|
-
// On resume (enterAutoWorktree), the symlink may be missing if it was
|
|
394
|
-
// created before ensureGsdSymlink existed, or the .gsd/ directory may be
|
|
395
|
-
// a stale git-tracked copy instead of a symlink. Refreshing here ensures
|
|
396
|
-
// the worktree sees the same milestone state as the main repo.
|
|
397
|
-
ensureGsdSymlink(p);
|
|
398
|
-
|
|
399
|
-
// Sync .gsd/ state from main repo into worktree (#1311).
|
|
400
|
-
// Covers the case where .gsd/ is a real directory (not symlinked) and
|
|
401
|
-
// milestones were created on main after the worktree was last used.
|
|
402
|
-
syncGsdStateToWorktree(basePath, p);
|
|
403
|
-
|
|
404
270
|
const previousCwd = process.cwd();
|
|
405
271
|
|
|
406
272
|
try {
|
|
@@ -52,20 +52,8 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
|
|
|
52
52
|
|
|
53
53
|
/** Resolve the effective project root, accounting for worktree paths. */
|
|
54
54
|
export function projectRoot(): string {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// When running inside a GSD worktree, the resolved root may be a "dangerous"
|
|
59
|
-
// directory (e.g., $HOME used as a git repo root — #1317). The safety check
|
|
60
|
-
// should validate the actual working directory, not the upstream root,
|
|
61
|
-
// because the worktree itself is a safe project subdirectory.
|
|
62
|
-
// Only skip the root check when we can confirm we're in a valid worktree.
|
|
63
|
-
if (root !== cwd) {
|
|
64
|
-
// We're in a worktree — validate the worktree path instead of the root
|
|
65
|
-
assertSafeDirectory(cwd);
|
|
66
|
-
} else {
|
|
67
|
-
assertSafeDirectory(root);
|
|
68
|
-
}
|
|
55
|
+
const root = resolveProjectRoot(process.cwd());
|
|
56
|
+
assertSafeDirectory(root);
|
|
69
57
|
return root;
|
|
70
58
|
}
|
|
71
59
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
-
import { existsSync, readFileSync,
|
|
20
|
+
import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
|
|
21
21
|
import { join, dirname } from "node:path";
|
|
22
22
|
import { gsdRoot } from "./paths.js";
|
|
23
23
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
@@ -54,81 +54,12 @@ let _lockPid: number = 0;
|
|
|
54
54
|
/** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
|
|
55
55
|
let _lockCompromised: boolean = false;
|
|
56
56
|
|
|
57
|
-
/** Whether we've already registered a process.on('exit') handler. */
|
|
58
|
-
let _exitHandlerRegistered: boolean = false;
|
|
59
|
-
|
|
60
57
|
const LOCK_FILE = "auto.lock";
|
|
61
58
|
|
|
62
59
|
function lockPath(basePath: string): string {
|
|
63
60
|
return join(gsdRoot(basePath), LOCK_FILE);
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
// ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
|
|
70
|
-
* that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
|
|
71
|
-
* or other filesystem-level copy-on-conflict behavior (#1315).
|
|
72
|
-
*
|
|
73
|
-
* Also removes stray proper-lockfile directories beyond the canonical `.gsd.lock/`.
|
|
74
|
-
*/
|
|
75
|
-
export function cleanupStrayLockFiles(basePath: string): void {
|
|
76
|
-
const gsdDir = gsdRoot(basePath);
|
|
77
|
-
|
|
78
|
-
// Clean numbered auto lock files inside .gsd/
|
|
79
|
-
try {
|
|
80
|
-
if (existsSync(gsdDir)) {
|
|
81
|
-
for (const entry of readdirSync(gsdDir)) {
|
|
82
|
-
// Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
|
|
83
|
-
if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
|
|
84
|
-
try { unlinkSync(join(gsdDir, entry)); } catch { /* best-effort */ }
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
} catch { /* non-fatal: directory read failure */ }
|
|
89
|
-
|
|
90
|
-
// Clean stray proper-lockfile directories (e.g. ".gsd 2.lock/")
|
|
91
|
-
// The canonical one is ".gsd.lock/" — anything else is stray.
|
|
92
|
-
try {
|
|
93
|
-
const parentDir = dirname(gsdDir);
|
|
94
|
-
const gsdDirName = gsdDir.split("/").pop() || ".gsd";
|
|
95
|
-
if (existsSync(parentDir)) {
|
|
96
|
-
for (const entry of readdirSync(parentDir)) {
|
|
97
|
-
// Match ".gsd <N>.lock" or ".gsd (<N>).lock" directories but NOT ".gsd.lock"
|
|
98
|
-
if (entry !== `${gsdDirName}.lock` && entry.startsWith(gsdDirName) && entry.endsWith(".lock")) {
|
|
99
|
-
const fullPath = join(parentDir, entry);
|
|
100
|
-
try {
|
|
101
|
-
const stat = statSync(fullPath);
|
|
102
|
-
if (stat.isDirectory()) {
|
|
103
|
-
rmSync(fullPath, { recursive: true, force: true });
|
|
104
|
-
}
|
|
105
|
-
} catch { /* best-effort */ }
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch { /* non-fatal */ }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Register a single process exit handler that cleans up lock state.
|
|
114
|
-
* Uses module-level references so it always operates on current state.
|
|
115
|
-
* Only registers once — subsequent calls are no-ops.
|
|
116
|
-
*/
|
|
117
|
-
function ensureExitHandler(gsdDir: string): void {
|
|
118
|
-
if (_exitHandlerRegistered) return;
|
|
119
|
-
_exitHandlerRegistered = true;
|
|
120
|
-
|
|
121
|
-
process.once("exit", () => {
|
|
122
|
-
try {
|
|
123
|
-
if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
|
|
124
|
-
} catch { /* best-effort */ }
|
|
125
|
-
try {
|
|
126
|
-
const lockDir = join(gsdDir + ".lock");
|
|
127
|
-
if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
|
|
128
|
-
} catch { /* best-effort */ }
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
63
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
133
64
|
|
|
134
65
|
/**
|
|
@@ -146,9 +77,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
146
77
|
// Ensure the directory exists
|
|
147
78
|
mkdirSync(dirname(lp), { recursive: true });
|
|
148
79
|
|
|
149
|
-
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
150
|
-
cleanupStrayLockFiles(basePath);
|
|
151
|
-
|
|
152
80
|
// Write our lock data first (the content is informational; the OS lock is the real guard)
|
|
153
81
|
const lockData: SessionLockData = {
|
|
154
82
|
pid: process.pid,
|
|
@@ -196,7 +124,15 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
196
124
|
|
|
197
125
|
// Safety net: clean up lock dir on process exit if _releaseFunction
|
|
198
126
|
// wasn't called (e.g., normal exit after clean completion) (#1245).
|
|
199
|
-
|
|
127
|
+
const lockDirForCleanup = join(gsdDir + ".lock");
|
|
128
|
+
process.once("exit", () => {
|
|
129
|
+
try {
|
|
130
|
+
if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
|
|
131
|
+
} catch { /* best-effort */ }
|
|
132
|
+
try {
|
|
133
|
+
if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
|
|
134
|
+
} catch { /* best-effort */ }
|
|
135
|
+
});
|
|
200
136
|
|
|
201
137
|
// Write the informational lock data
|
|
202
138
|
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
|
|
@@ -222,15 +158,18 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
222
158
|
update: 10_000,
|
|
223
159
|
onCompromised: () => {
|
|
224
160
|
_lockCompromised = true;
|
|
225
|
-
_releaseFunction = null;
|
|
226
161
|
},
|
|
227
162
|
});
|
|
228
163
|
_releaseFunction = release;
|
|
229
164
|
_lockedPath = basePath;
|
|
230
165
|
_lockPid = process.pid;
|
|
231
166
|
|
|
232
|
-
// Safety net
|
|
233
|
-
|
|
167
|
+
// Safety net for retry path too
|
|
168
|
+
const retryLockDir = join(gsdDir + ".lock");
|
|
169
|
+
process.once("exit", () => {
|
|
170
|
+
try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch {}
|
|
171
|
+
try { if (existsSync(retryLockDir)) rmSync(retryLockDir, { recursive: true, force: true }); } catch {}
|
|
172
|
+
});
|
|
234
173
|
|
|
235
174
|
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
|
|
236
175
|
return { acquired: true };
|
|
@@ -371,9 +310,6 @@ export function releaseSessionLock(basePath: string): void {
|
|
|
371
310
|
// Non-fatal
|
|
372
311
|
}
|
|
373
312
|
|
|
374
|
-
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
375
|
-
cleanupStrayLockFiles(basePath);
|
|
376
|
-
|
|
377
313
|
_lockedPath = null;
|
|
378
314
|
_lockPid = 0;
|
|
379
315
|
_lockCompromised = false;
|
|
@@ -609,48 +609,10 @@ test("session lock: onCompromised handler exists in both primary and retry paths
|
|
|
609
609
|
const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)];
|
|
610
610
|
// Should have at least 2 onCompromised handlers (primary + retry)
|
|
611
611
|
// plus the flag declaration and the check in validateSessionLock
|
|
612
|
-
assert.ok(compromisedMatches.length >= 3,
|
|
612
|
+
assert.ok(compromisedMatches.length >= 3,
|
|
613
613
|
`expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`);
|
|
614
614
|
});
|
|
615
615
|
|
|
616
|
-
test("session lock: both onCompromised handlers null _releaseFunction (#1315)", async () => {
|
|
617
|
-
const lockSource = readFileSync(
|
|
618
|
-
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
|
|
619
|
-
);
|
|
620
|
-
// Extract onCompromised handler blocks — both should set _releaseFunction = null
|
|
621
|
-
const handlers = lockSource.match(/onCompromised:\s*\(\)\s*=>\s*\{[^}]+\}/g) || [];
|
|
622
|
-
assert.ok(handlers.length >= 2, `expected ≥2 onCompromised handlers, got ${handlers.length}`);
|
|
623
|
-
for (const h of handlers) {
|
|
624
|
-
assert.ok(h.includes("_releaseFunction = null"),
|
|
625
|
-
`onCompromised handler should null _releaseFunction: ${h}`);
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
test("session lock: exit handler uses ensureExitHandler to prevent double-registration (#1315)", async () => {
|
|
630
|
-
const lockSource = readFileSync(
|
|
631
|
-
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
|
|
632
|
-
);
|
|
633
|
-
// Should use ensureExitHandler instead of direct process.once("exit") in acquire paths
|
|
634
|
-
const directExitHandlers = (lockSource.match(/process\.once\("exit"/g) || []).length;
|
|
635
|
-
const ensureExitCalls = (lockSource.match(/ensureExitHandler\(/g) || []).length;
|
|
636
|
-
// Only 1 direct process.once("exit") allowed — inside ensureExitHandler itself
|
|
637
|
-
assert.ok(directExitHandlers <= 1,
|
|
638
|
-
`expected ≤1 direct process.once("exit") (inside ensureExitHandler), got ${directExitHandlers}`);
|
|
639
|
-
assert.ok(ensureExitCalls >= 2,
|
|
640
|
-
`expected ≥2 ensureExitHandler calls (primary + retry path), got ${ensureExitCalls}`);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
test("signal handler: SIGINT handler registered alongside SIGTERM (#1315)", async () => {
|
|
644
|
-
const supervisorSource = readFileSync(
|
|
645
|
-
"src/resources/extensions/gsd/auto-supervisor.ts", "utf-8"
|
|
646
|
-
);
|
|
647
|
-
// registerSigtermHandler should register on both SIGTERM and SIGINT
|
|
648
|
-
assert.ok(supervisorSource.includes('process.on("SIGINT"') || supervisorSource.includes("process.on('SIGINT'"),
|
|
649
|
-
"registerSigtermHandler should register SIGINT handler");
|
|
650
|
-
assert.ok(supervisorSource.includes('process.off("SIGINT"') || supervisorSource.includes("process.off('SIGINT'"),
|
|
651
|
-
"deregisterSigtermHandler should deregister SIGINT handler");
|
|
652
|
-
});
|
|
653
|
-
|
|
654
616
|
// ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ────────────
|
|
655
617
|
|
|
656
618
|
test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => {
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
readSessionLockData,
|
|
13
13
|
isSessionLockHeld,
|
|
14
14
|
isSessionLockProcessAlive,
|
|
15
|
-
cleanupStrayLockFiles,
|
|
16
15
|
} from "../session-lock.ts";
|
|
17
16
|
|
|
18
17
|
// ─── acquireSessionLock ──────────────────────────────────────────────────
|
|
@@ -314,121 +313,3 @@ test("acquireSessionLock creates .gsd/ if it does not exist", () => {
|
|
|
314
313
|
releaseSessionLock(dir);
|
|
315
314
|
rmSync(dir, { recursive: true, force: true });
|
|
316
315
|
});
|
|
317
|
-
|
|
318
|
-
// ─── cleanupStrayLockFiles (#1315) ──────────────────────────────────────
|
|
319
|
-
|
|
320
|
-
test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => {
|
|
321
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
322
|
-
const gsdDir = join(dir, ".gsd");
|
|
323
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
324
|
-
|
|
325
|
-
// Create canonical lock file + numbered variants
|
|
326
|
-
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
327
|
-
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}');
|
|
328
|
-
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}');
|
|
329
|
-
writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}');
|
|
330
|
-
|
|
331
|
-
cleanupStrayLockFiles(dir);
|
|
332
|
-
|
|
333
|
-
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
334
|
-
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed");
|
|
335
|
-
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed");
|
|
336
|
-
assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed");
|
|
337
|
-
|
|
338
|
-
rmSync(dir, { recursive: true, force: true });
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
test("cleanupStrayLockFiles handles parenthesized variants", () => {
|
|
342
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
343
|
-
const gsdDir = join(dir, ".gsd");
|
|
344
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
345
|
-
|
|
346
|
-
// macOS sometimes uses parenthesized format: "auto (2).lock"
|
|
347
|
-
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
348
|
-
writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}');
|
|
349
|
-
|
|
350
|
-
cleanupStrayLockFiles(dir);
|
|
351
|
-
|
|
352
|
-
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
353
|
-
assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed");
|
|
354
|
-
|
|
355
|
-
rmSync(dir, { recursive: true, force: true });
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
test("cleanupStrayLockFiles does not remove unrelated files", () => {
|
|
359
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
360
|
-
const gsdDir = join(dir, ".gsd");
|
|
361
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
362
|
-
|
|
363
|
-
// Create unrelated files that should NOT be removed
|
|
364
|
-
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
365
|
-
writeFileSync(join(gsdDir, "config.json"), '{}');
|
|
366
|
-
writeFileSync(join(gsdDir, "other.lock"), '{}');
|
|
367
|
-
|
|
368
|
-
cleanupStrayLockFiles(dir);
|
|
369
|
-
|
|
370
|
-
assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved");
|
|
371
|
-
assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved");
|
|
372
|
-
assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved");
|
|
373
|
-
|
|
374
|
-
rmSync(dir, { recursive: true, force: true });
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("cleanupStrayLockFiles is safe on empty directory", () => {
|
|
378
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
379
|
-
const gsdDir = join(dir, ".gsd");
|
|
380
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
381
|
-
|
|
382
|
-
// Should not throw
|
|
383
|
-
cleanupStrayLockFiles(dir);
|
|
384
|
-
|
|
385
|
-
rmSync(dir, { recursive: true, force: true });
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => {
|
|
389
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
390
|
-
|
|
391
|
-
// Should not throw even without .gsd/
|
|
392
|
-
cleanupStrayLockFiles(dir);
|
|
393
|
-
|
|
394
|
-
rmSync(dir, { recursive: true, force: true });
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
test("acquireSessionLock cleans stray lock files before acquiring", () => {
|
|
398
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
399
|
-
const gsdDir = join(dir, ".gsd");
|
|
400
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
401
|
-
|
|
402
|
-
// Plant stray lock files before acquire
|
|
403
|
-
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
404
|
-
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}');
|
|
405
|
-
|
|
406
|
-
const result = acquireSessionLock(dir);
|
|
407
|
-
assert.equal(result.acquired, true, "should acquire lock");
|
|
408
|
-
|
|
409
|
-
// Stray files should be cleaned up
|
|
410
|
-
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire");
|
|
411
|
-
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire");
|
|
412
|
-
|
|
413
|
-
releaseSessionLock(dir);
|
|
414
|
-
rmSync(dir, { recursive: true, force: true });
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
test("releaseSessionLock cleans stray lock files after releasing", () => {
|
|
418
|
-
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
419
|
-
const gsdDir = join(dir, ".gsd");
|
|
420
|
-
mkdirSync(gsdDir, { recursive: true });
|
|
421
|
-
|
|
422
|
-
const result = acquireSessionLock(dir);
|
|
423
|
-
assert.equal(result.acquired, true);
|
|
424
|
-
|
|
425
|
-
// Plant stray lock files (simulating cloud sync creating them during session)
|
|
426
|
-
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
427
|
-
|
|
428
|
-
releaseSessionLock(dir);
|
|
429
|
-
|
|
430
|
-
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release");
|
|
431
|
-
assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed");
|
|
432
|
-
|
|
433
|
-
rmSync(dir, { recursive: true, force: true });
|
|
434
|
-
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "mcporter",
|
|
3
|
+
"name": "MCPorter",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Discover and call tools from MCP servers configured in Claude Desktop, Cursor, and VS Code",
|
|
6
|
+
"tier": "bundled",
|
|
7
|
+
"requires": { "platform": ">=2.29.0" },
|
|
8
|
+
"provides": {
|
|
9
|
+
"tools": ["mcp_servers", "mcp_discover", "mcp_call"],
|
|
10
|
+
"hooks": ["session_start"]
|
|
11
|
+
}
|
|
12
|
+
}
|