@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -1,6 +1,45 @@
1
+ /**
2
+ * Per-session file-read cache + stale-read gate.
3
+ *
4
+ * Leak intel L1 (openclaude `FileEditTool.ts`, 2026-05-27 gap analysis
5
+ * §5.1): every FileEdit must validate the operator's last-known view of
6
+ * the file before mutating disk. The gate compares BOTH `mtimeMs` and
7
+ * `sha256(content)` of the file on disk against the record captured at
8
+ * read time:
9
+ *
10
+ * - mtimeMs is a cheap fast-path. If the inode mtime hasn't moved
11
+ * since the read, the content hash cannot have changed (barring a
12
+ * filesystem with hash-on-mtime-skew bugs) and we can short-circuit.
13
+ * - sha256 is the authoritative gate. A user editor that writes back
14
+ * identical content can leave mtime untouched on some filesystems
15
+ * (atomic-rename with preserved metadata), and conversely `touch`
16
+ * bumps mtime without changing content. Hash is the truth.
17
+ *
18
+ * Both signals must agree for the gate to PASS. Any divergence => STALE
19
+ * => refuse the edit, force the model to re-read.
20
+ *
21
+ * Cache lifetime: per-session. `FileReadCache.clear()` is called at
22
+ * session.end (see `core/session.ts`). The cache is intentionally NOT
23
+ * durable across sessions — a re-read after restart is cheap and stale
24
+ * cross-session entries would themselves be a soundness hazard.
25
+ *
26
+ * Exception: writeTool for create-new (path doesn't exist on disk) does
27
+ * not consult the cache. Creating a brand new file has no "last-known
28
+ * view" to invalidate.
29
+ */
1
30
  import { createHash } from 'node:crypto';
2
- import { statSync } from 'node:fs';
31
+ import { existsSync, statSync } from 'node:fs';
3
32
  import { resolve } from 'node:path';
33
+ export class StaleReadError extends Error {
34
+ reason;
35
+ path;
36
+ constructor(path, reason, detail) {
37
+ super(`stale_read: ${path} — ${detail}. Re-read the file before editing.`);
38
+ this.name = 'StaleReadError';
39
+ this.reason = reason;
40
+ this.path = path;
41
+ }
42
+ }
4
43
  export class FileReadCache {
5
44
  records = new Map();
6
45
  set(record) {
@@ -9,6 +48,70 @@ export class FileReadCache {
9
48
  get(root, path) {
10
49
  return this.records.get(resolve(root, path));
11
50
  }
51
+ /**
52
+ * Validate a candidate edit against the cached read record. Returns
53
+ * a tagged-union: `{ stale: false }` when the edit may proceed, or
54
+ * `{ stale: true, reason, detail }` when the gate must refuse.
55
+ *
56
+ * Pure function over the cache + supplied `currentMtimeMs` /
57
+ * `currentContent` — does NOT touch disk. Callers (editTool /
58
+ * writeTool) do their own `statSync` + `readFileSync` because they
59
+ * also need the content for the diff/edit itself.
60
+ *
61
+ * @param root workspace root (used to resolve relative path)
62
+ * @param path workspace-relative file path
63
+ * @param currentMtimeMs `fs.statSync().mtimeMs` of the on-disk file
64
+ * @param currentContent UTF-8 contents of the on-disk file
65
+ */
66
+ validate(root, path, currentMtimeMs, currentContent) {
67
+ const record = this.get(root, path);
68
+ if (!record) {
69
+ return {
70
+ stale: true,
71
+ reason: 'no_prior_read',
72
+ detail: 'file must be read first',
73
+ };
74
+ }
75
+ // Fast-path: mtime hasn't moved. Hash check is redundant in the
76
+ // common case but cheap, so we still verify below. Skipping hash
77
+ // when mtime matches would allow a subtle bug class (in-place
78
+ // writers that preserve mtime) to slip through.
79
+ if (currentMtimeMs > record.mtimeMs) {
80
+ // mtime advanced — confirm with hash before flagging. A bump
81
+ // without a content change (e.g. `touch`) shouldn't fire stale.
82
+ const currentHash = hashContent(currentContent);
83
+ if (currentHash !== record.sha256) {
84
+ return {
85
+ stale: true,
86
+ reason: 'mtime_drift',
87
+ detail: `mtime advanced (${record.mtimeMs} → ${currentMtimeMs}) and content hash diverged`,
88
+ };
89
+ }
90
+ // mtime bumped but content identical — treat as fresh. The cache
91
+ // entry's mtime is intentionally NOT refreshed here; the next
92
+ // edit will hit the same path and the gate will keep agreeing.
93
+ return { stale: false };
94
+ }
95
+ // mtime hasn't moved — hash MUST still match the record. A
96
+ // mismatch is a filesystem-level inconsistency or an in-place
97
+ // editor that preserves mtime; either way, refuse.
98
+ const currentHash = hashContent(currentContent);
99
+ if (currentHash !== record.sha256) {
100
+ return {
101
+ stale: true,
102
+ reason: 'hash_drift',
103
+ detail: 'content hash diverged from last read (mtime unchanged)',
104
+ };
105
+ }
106
+ return { stale: false };
107
+ }
108
+ /**
109
+ * Drop every cached record. Called by session.end so a fresh REPL
110
+ * session never inherits stale cross-session entries.
111
+ */
112
+ clear() {
113
+ this.records.clear();
114
+ }
12
115
  }
13
116
  export function hashContent(content) {
14
117
  return createHash('sha256').update(content).digest('hex');
@@ -26,4 +129,13 @@ export function createReadRecord(root, path, content, source) {
26
129
  source,
27
130
  };
28
131
  }
132
+ /**
133
+ * Convenience helper: does this absolute path exist on disk? Wraps the
134
+ * existsSync import so file-tools.ts can decide between create-new
135
+ * (skip stale gate) and update-existing (apply stale gate) without
136
+ * pulling in another fs import.
137
+ */
138
+ export function pathExists(absolutePath) {
139
+ return existsSync(absolutePath);
140
+ }
29
141
  //# sourceMappingURL=file-cache.js.map
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Workspace scaffold — extracted from `pugi init` so the bare REPL boot
3
+ * can call it automatically when the operator launches `pugi` in a
4
+ * fresh directory (CEO directive 2026-05-26).
5
+ *
6
+ * Before this module, `pugi init` was the only path that materialised
7
+ * `.pugi/` + the canonical config files. Launching the REPL in an empty
8
+ * directory printed `workspace: (not bound - run /init OR cd into
9
+ * project)` and instructed the operator to Ctrl+C, run `pugi init`,
10
+ * relaunch. That round trip is hostile on a first-touch install — CEO
11
+ * escalated "auto = решение" on 2026-05-26.
12
+ *
13
+ * The module is intentionally side-effect free at import time: the
14
+ * scaffold runs only when `ensureWorkspaceInitialized` is called. The
15
+ * scaffold is also idempotent — every file write is gated by an
16
+ * `existsSync` check, so re-running against a workspace that already has
17
+ * `.pugi/settings.json` (e.g. a manual `pugi init` followed by auto-init
18
+ * on next REPL launch) is a no-op. The function is safe to call before
19
+ * any other init logic.
20
+ *
21
+ * Two CRITICAL invariants:
22
+ *
23
+ * 1. **Atomic per-file.** Every write uses `existsSync` + `writeFileSync`
24
+ * against the final path. There is no read-modify-write pattern that
25
+ * could lose data on a concurrent `pugi init` race. The one path
26
+ * that DOES mutate an existing file — `.gitignore` (append `.pugi/`
27
+ * marker) — also gates on the marker being absent before appending,
28
+ * so the worst-case race is a duplicate marker line that the next
29
+ * run skips.
30
+ *
31
+ * 2. **Silent by default.** When `opts.silent` is true (the REPL
32
+ * auto-init path) the scaffold writes NOTHING to stderr/stdout.
33
+ * The REPL bootstrap runs before Ink mounts, and a stray
34
+ * stdout/stderr write at that point would land on the operator's
35
+ * shell ABOVE the alt-screen entry — visible until they scroll up,
36
+ * and noisy in a CI tail. The explicit `pugi init` path stays
37
+ * verbose via the standalone command in `runtime/cli.ts`.
38
+ */
39
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
40
+ import { resolve } from 'node:path';
41
+ import { emptyIndex } from '../index-store.js';
42
+ /**
43
+ * Materialise the canonical `.pugi/` workspace scaffold under `cwd`.
44
+ * Returns a `{created, dir, createdPaths, skippedPaths}` summary so the
45
+ * caller can log a one-shot "initialized" line on the first call without
46
+ * re-checking the filesystem.
47
+ *
48
+ * The scaffold mirrors `pugi init` minus the bundled default-skills
49
+ * install (that is a heavier operation gated on the `--no-defaults`
50
+ * flag, and the standalone `pugi init` command keeps owning it).
51
+ *
52
+ * Idempotent: every file write gates on `existsSync`, so re-running
53
+ * against an existing workspace is a no-op and returns
54
+ * `{created: false}` with every path in `skippedPaths`.
55
+ */
56
+ export function ensureWorkspaceInitialized(cwd, opts = {}) {
57
+ const silent = opts.silent !== false;
58
+ const pugiDir = resolve(cwd, '.pugi');
59
+ // Local trackers so the existing helpers (mkdirIfMissing /
60
+ // writeJsonIfMissing / writeTextIfMissing) keep their (created, skipped)
61
+ // signature. The explicit `pugi init` command forwards these straight
62
+ // into its JSON payload.
63
+ const created = [];
64
+ const skipped = [];
65
+ mkdirIfMissing(pugiDir, created, skipped);
66
+ mkdirIfMissing(resolve(pugiDir, 'artifacts'), created, skipped);
67
+ mkdirIfMissing(resolve(pugiDir, 'sessions'), created, skipped);
68
+ mkdirIfMissing(resolve(pugiDir, 'skills'), created, skipped);
69
+ writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
70
+ schema: 1,
71
+ workflow: {
72
+ brand: 'pugi',
73
+ legacyName: 'codeforge',
74
+ approvals: 'auto',
75
+ notAutomatic: [],
76
+ defaultBaseBranch: 'dev',
77
+ branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
78
+ aiCoAuthorTrailers: false,
79
+ },
80
+ permissions: {
81
+ mode: 'auto',
82
+ allow: [],
83
+ deny: [],
84
+ notAutomatic: [],
85
+ },
86
+ privacy: {
87
+ mode: 'balanced',
88
+ telemetry: 'off',
89
+ },
90
+ artifacts: {
91
+ defaultPath: '.pugi/artifacts',
92
+ promoteExplicitly: true,
93
+ },
94
+ }, created, skipped);
95
+ writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), { schema: 1, servers: [] }, created, skipped);
96
+ writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
97
+ writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
98
+ '# Pugi Project Context',
99
+ '',
100
+ '## Product Workflow',
101
+ '',
102
+ '- Public product name: Pugi',
103
+ '- Default flow: idea -> build -> review',
104
+ '- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
105
+ '- Do not add AI Co-Authored-By trailers.',
106
+ '- Generated code, comments, commits, PR text, and technical docs default to English.',
107
+ '',
108
+ '## Project Notes',
109
+ '',
110
+ '- Add repo-specific architecture, commands, and business rules here.',
111
+ '- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
112
+ '',
113
+ ].join('\n'), created, skipped);
114
+ writeTextIfMissing(resolve(cwd, '.pugiignore'), [
115
+ '# Pugi ignore rules',
116
+ '.env',
117
+ '.env.*',
118
+ '!.env.example',
119
+ 'node_modules/',
120
+ 'dist/',
121
+ '.next/',
122
+ 'coverage/',
123
+ '*.log',
124
+ '*.pem',
125
+ '*.key',
126
+ '*.crt',
127
+ '*.p12',
128
+ '*.sql',
129
+ '*.dump',
130
+ '',
131
+ ].join('\n'), created, skipped);
132
+ ensurePugiGitIgnore(cwd, created, skipped);
133
+ // `silent` is honoured implicitly — this module never writes to
134
+ // stdout/stderr. The flag exists so the standalone `pugi init` command
135
+ // can layer its own logger on top (it does, in runtime/cli.ts), while
136
+ // the auto-init REPL path leaves the boot stream untouched. We
137
+ // reference the flag here to defeat the lint "unused" warning and to
138
+ // document the contract in the source.
139
+ void silent;
140
+ return {
141
+ created: created.length > 0,
142
+ dir: pugiDir,
143
+ createdPaths: created,
144
+ skippedPaths: skipped,
145
+ };
146
+ }
147
+ /* ------------------------------------------------------------------ */
148
+ /* Helpers (mirror the previous in-file implementations in cli.ts) */
149
+ /* ------------------------------------------------------------------ */
150
+ function mkdirIfMissing(path, created, skipped) {
151
+ if (existsSync(path)) {
152
+ skipped.push(path);
153
+ return;
154
+ }
155
+ mkdirSync(path, { recursive: true });
156
+ created.push(path);
157
+ }
158
+ function writeJsonIfMissing(path, value, created, skipped) {
159
+ writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
160
+ }
161
+ function writeTextIfMissing(path, value, created, skipped) {
162
+ if (existsSync(path)) {
163
+ skipped.push(path);
164
+ return;
165
+ }
166
+ writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
167
+ created.push(path);
168
+ }
169
+ /**
170
+ * Ensure the workspace `.gitignore` ignores `.pugi/`. The function is
171
+ * additive: it leaves an existing `.gitignore` body intact and appends
172
+ * the marker only when none of `.pugi/`, `/.pugi/`, or `.pugi` is
173
+ * already present. On a fresh repo with no `.gitignore` it creates the
174
+ * file with the single marker line. Mode 0o600 matches the rest of the
175
+ * scaffold so a paranoid CI does not surface "world-readable" warnings.
176
+ */
177
+ function ensurePugiGitIgnore(cwd, created, skipped) {
178
+ const gitignorePath = resolve(cwd, '.gitignore');
179
+ const marker = '.pugi/';
180
+ if (!existsSync(gitignorePath)) {
181
+ writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
182
+ created.push(gitignorePath);
183
+ return;
184
+ }
185
+ const current = readFileSync(gitignorePath, 'utf8');
186
+ const lines = current.split('\n').map((line) => line.trim());
187
+ if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
188
+ skipped.push(gitignorePath);
189
+ return;
190
+ }
191
+ const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
192
+ writeFileSync(gitignorePath, next, { encoding: 'utf8' });
193
+ created.push(`${gitignorePath} (+${marker})`);
194
+ }
195
+ //# sourceMappingURL=scaffold.js.map
@@ -62,7 +62,7 @@
62
62
  */
63
63
  import { spawn, spawnSync } from 'node:child_process';
64
64
  import { pathToFileURL } from 'node:url';
65
- import { readFileSync } from 'node:fs';
65
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
66
66
  import { resolve, sep } from 'node:path';
67
67
  import { OperatorAbortedError } from '../../tools/file-tools.js';
68
68
  const LANGUAGE_SERVERS = {
@@ -130,6 +130,13 @@ export class LspClient {
130
130
  child.stderr.on('data', () => { });
131
131
  }
132
132
  child.on('exit', () => this.onExit());
133
+ // R1 fix (2026-05-26, PR #413 r1, P2 #11): mirror onExit for the
134
+ // 'error' event. A late-fired spawn error (EIO, ENOMEM, etc.) or
135
+ // any unhandled child-process error would otherwise leave
136
+ // in-flight pending requests dangling until their per-request
137
+ // timer fired, which can be up to `requestTimeoutMs` later.
138
+ // Failing fast here matches the exit-time semantics.
139
+ child.on('error', () => this.onExit());
133
140
  }
134
141
  /**
135
142
  * Send `shutdown` + `exit`, then SIGKILL after a 1s grace window so
@@ -253,6 +260,35 @@ export class LspClient {
253
260
  detail: error instanceof Error ? error.message : String(error),
254
261
  };
255
262
  }
263
+ // R1 fix (2026-05-26, PR #413 r1, Fix 8): realpath containment.
264
+ // Without this gate, a workspace-local symlink (e.g. `alias` ->
265
+ // `/etc/passwd`) passed the lexical `absPath.startsWith(cwd)`
266
+ // check, then `readFileSync(absPath, 'utf8')` happily followed the
267
+ // symlink and shipped `/etc/passwd` into the LSP `textDocument/didOpen`
268
+ // payload. Parity with `applySecurityGate`'s symlink-escape rule:
269
+ // when the file exists, the realpath MUST stay inside the workspace
270
+ // realpath. Missing files (newly-typed paths the operator is
271
+ // querying) skip the check — there's no symlink target to escape.
272
+ if (existsSync(absPath)) {
273
+ try {
274
+ const realRoot = realpathSync.native(this.cwd);
275
+ const realTarget = realpathSync.native(absPath);
276
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
277
+ return {
278
+ ok: false,
279
+ reason: 'lsp_error',
280
+ detail: `symlink escapes workspace: ${file} -> ${realTarget}`,
281
+ };
282
+ }
283
+ }
284
+ catch (error) {
285
+ return {
286
+ ok: false,
287
+ reason: 'lsp_error',
288
+ detail: `cannot realpath ${file}: ${error instanceof Error ? error.message : String(error)}`,
289
+ };
290
+ }
291
+ }
256
292
  const uri = pathToFileURL(absPath).toString();
257
293
  if (!this.openedFiles.has(uri)) {
258
294
  try {
@@ -349,8 +385,22 @@ export class LspClient {
349
385
  const headerText = this.buffer.subarray(0, headerEnd).toString('ascii');
350
386
  const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
351
387
  if (!lengthMatch || lengthMatch[1] === undefined) {
352
- // Malformed header drop the buffer and resync at the next message.
353
- this.buffer = Buffer.alloc(0);
388
+ // R1 fix (2026-05-26, PR #413 r1, Fix 7): malformed header
389
+ // instead of nuking the entire buffer (which would discard ANY
390
+ // subsequent valid messages already queued in `this.buffer`),
391
+ // scan forward for the next `Content-Length:` marker and resync
392
+ // from there. A misbehaving server that emits one bad header
393
+ // followed by a normal stream of responses must not freeze the
394
+ // client. When no recoverable next marker is in the buffer, we
395
+ // keep the buffer as-is and wait for more data — the broken
396
+ // bytes will be re-evaluated on the next chunk.
397
+ const nextHeaderIdx = this.buffer.indexOf(Buffer.from('Content-Length:'), 1);
398
+ if (nextHeaderIdx > 0) {
399
+ this.buffer = this.buffer.subarray(nextHeaderIdx);
400
+ continue;
401
+ }
402
+ // No next marker visible — wait for more data, do not nuke the
403
+ // buffer. A subsequent chunk may complete a valid header.
354
404
  return;
355
405
  }
356
406
  const length = Number.parseInt(lengthMatch[1], 10);
@@ -415,14 +465,59 @@ export class LspClient {
415
465
  }
416
466
  }
417
467
  }
468
+ /**
469
+ * Map a short LSP language slug to the settings.json key. β7 L9 — the
470
+ * settings schema spells out the full language name (`typescript`,
471
+ * `python`, ...) for human readability; the short slug (`ts`, `py`) is
472
+ * what every internal call site uses. Keep this map narrow and explicit.
473
+ */
474
+ const SETTINGS_KEY_BY_LANG = {
475
+ ts: 'typescript',
476
+ js: 'javascript',
477
+ py: 'python',
478
+ go: 'go',
479
+ rust: 'rust',
480
+ };
481
+ /**
482
+ * Report whether the operator has explicitly disabled this language via
483
+ * `.pugi/settings.json::lsp.<language> = false`. Absent section or
484
+ * absent key means "enabled by default" — backwards-compatible with the
485
+ * α7.7 surface that ignored settings entirely. Returns true ONLY when
486
+ * the operator explicitly set the value to false.
487
+ */
488
+ export function isLspLanguageDisabled(lang, lspSettings) {
489
+ if (!lspSettings)
490
+ return false;
491
+ const key = SETTINGS_KEY_BY_LANG[lang];
492
+ return lspSettings[key] === false;
493
+ }
494
+ /**
495
+ * Probe every registered language server. Operator-facing helper for
496
+ * `pugi lsp servers` — returns one row per language with the binary
497
+ * name, whether it was found on PATH, and whether the settings toggle
498
+ * has explicitly disabled it.
499
+ */
500
+ export function inspectLspServers(lspSettings) {
501
+ const out = [];
502
+ for (const lang of Object.keys(LANGUAGE_SERVERS)) {
503
+ const server = LANGUAGE_SERVERS[lang];
504
+ out.push({
505
+ language: lang,
506
+ command: server.command + (server.args.length > 0 ? ` ${server.args.join(' ')}` : ''),
507
+ available: detectBinary(server.probe),
508
+ enabled: !isLspLanguageDisabled(lang, lspSettings),
509
+ });
510
+ }
511
+ return out;
512
+ }
418
513
  /**
419
514
  * Start an LSP client for the given language. Returns either an `LspClient`
420
515
  * ready to use, or a structured failure (`lsp_unavailable`,
421
516
  * `language_unsupported`).
422
517
  *
423
- * The returned client requires `await client.initialize()` BEFORE any
424
- * operation. We do not inline the initialize into the spawn so test
425
- * harnesses can intercept the handshake and assert on its contents.
518
+ * β7 L9: respects `.pugi/settings.json::lsp.<language> = false`
519
+ * a disabled language reports `lsp_disabled` so the caller surface can
520
+ * tell the operator the binary IS available but settings says no.
426
521
  */
427
522
  export async function startLspClient(lang, opts) {
428
523
  const server = opts.serverOverride ?? LANGUAGE_SERVERS[lang];
@@ -433,6 +528,14 @@ export async function startLspClient(lang, opts) {
433
528
  detail: `no LSP server registered for language: ${lang}`,
434
529
  };
435
530
  }
531
+ if (!opts.serverOverride && isLspLanguageDisabled(lang, opts.lspSettings)) {
532
+ return {
533
+ ok: false,
534
+ reason: 'lsp_disabled',
535
+ detail: `${lang} is disabled in .pugi/settings.json::lsp.${SETTINGS_KEY_BY_LANG[lang]}. ` +
536
+ `Remove the override (or set it to true) to enable.`,
537
+ };
538
+ }
436
539
  if (!opts.serverOverride) {
437
540
  const available = detectBinary(server.probe);
438
541
  if (!available) {
@@ -459,12 +562,51 @@ export async function startLspClient(lang, opts) {
459
562
  detail: `failed to spawn ${server.command}: ${error instanceof Error ? error.message : String(error)}`,
460
563
  };
461
564
  }
565
+ // `child_process.spawn` reports a missing binary asynchronously via
566
+ // the 'error' event, NOT via a synchronous throw — the synchronous
567
+ // spawn returns a ChildProcess object even when the binary does not
568
+ // exist. Attach an error listener immediately so the missing-binary
569
+ // case never becomes an uncaught exception. Wait one microtask tick
570
+ // for the event-loop to fire the 'error' event before we attempt
571
+ // the handshake; if the spawn failed, return early with
572
+ // `lsp_unavailable`.
573
+ let spawnError = null;
574
+ child.on('error', (err) => {
575
+ spawnError = err;
576
+ });
577
+ // Yield one tick so Node's spawn-error path lands before we
578
+ // proceed. The error event lives on the same nextTick queue as the
579
+ // initial spawn handshake, so a single setImmediate-equivalent
580
+ // delay is enough to observe it.
581
+ await new Promise((resolveFn) => {
582
+ setImmediate(resolveFn);
583
+ });
584
+ if (spawnError) {
585
+ try {
586
+ child.kill('SIGKILL');
587
+ }
588
+ catch {
589
+ // ignore — process never started
590
+ }
591
+ return {
592
+ ok: false,
593
+ reason: 'lsp_unavailable',
594
+ detail: `failed to spawn ${server.command}: ${spawnError.message}`,
595
+ };
596
+ }
462
597
  const client = new LspClient(child, server, opts);
463
598
  try {
464
599
  await initializeHandshake(client, opts.cwd);
465
600
  }
466
601
  catch (error) {
467
602
  await client.stop();
603
+ if (spawnError) {
604
+ return {
605
+ ok: false,
606
+ reason: 'lsp_unavailable',
607
+ detail: `failed to spawn ${server.command}: ${spawnError.message}`,
608
+ };
609
+ }
468
610
  return {
469
611
  ok: false,
470
612
  reason: 'lsp_error',
@@ -475,10 +617,11 @@ export async function startLspClient(lang, opts) {
475
617
  }
476
618
  async function initializeHandshake(client, cwd) {
477
619
  const rootUri = pathToFileURL(cwd).toString();
478
- // Use the public sendRequest path via a method call. We piggyback on
479
- // `LspClient`'s internal `sendRequest` by exposing a single test-grade
480
- // surface (`__lspRaw__`); production callers never need this.
481
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
620
+ // Reach the private send-request / send-notification surface through
621
+ // a typed accessor cast. The two methods are intentionally not part
622
+ // of the public class surface (callers should use `hover`/`definition`
623
+ // etc.), but the handshake is a single-shot bootstrap and exposing
624
+ // the raw methods would weaken the type story.
482
625
  const internal = client;
483
626
  await internal.sendRequest('initialize', {
484
627
  processId: process.pid,
@@ -520,27 +663,29 @@ function normalizeHover(raw) {
520
663
  const obj = raw;
521
664
  const range = parseRange(obj.range);
522
665
  const body = obj.contents;
523
- if (typeof body === 'string') {
524
- return { content: body, range, raw };
525
- }
526
- if (Array.isArray(body)) {
527
- const parts = [];
528
- for (const item of body) {
529
- if (typeof item === 'string')
530
- parts.push(item);
531
- else if (item && typeof item === 'object' && 'value' in item) {
532
- const value = item.value;
533
- if (typeof value === 'string')
534
- parts.push(value);
666
+ const result = (() => {
667
+ if (typeof body === 'string')
668
+ return { content: body, raw, ...(range ? { range } : {}) };
669
+ if (Array.isArray(body)) {
670
+ const parts = [];
671
+ for (const item of body) {
672
+ if (typeof item === 'string')
673
+ parts.push(item);
674
+ else if (item && typeof item === 'object' && 'value' in item) {
675
+ const value = item.value;
676
+ if (typeof value === 'string')
677
+ parts.push(value);
678
+ }
535
679
  }
680
+ return { content: parts.join('\n'), raw, ...(range ? { range } : {}) };
536
681
  }
537
- return { content: parts.join('\n'), range, raw };
538
- }
539
- if (body && typeof body === 'object' && 'value' in body) {
540
- const value = body.value;
541
- return { content: typeof value === 'string' ? value : '', range, raw };
542
- }
543
- return { content: '', range, raw };
682
+ if (body && typeof body === 'object' && 'value' in body) {
683
+ const value = body.value;
684
+ return { content: typeof value === 'string' ? value : '', raw, ...(range ? { range } : {}) };
685
+ }
686
+ return { content: '', raw, ...(range ? { range } : {}) };
687
+ })();
688
+ return result;
544
689
  }
545
690
  function normalizeLocations(raw, cwd) {
546
691
  if (!raw)