@pugi/cli 0.1.0-beta.94 → 0.1.0-beta.96
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.
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AnvilEngineLoopClient } from '../engine/anvil-client.js';
|
|
2
2
|
import { NativePugiEngineAdapter } from '../engine/native-pugi.js';
|
|
3
|
+
import { loadMcpRegistry as defaultLoadMcpRegistry } from '../mcp/registry.js';
|
|
3
4
|
import { openSession } from '../session.js';
|
|
5
|
+
import { loadHookRegistryOrExit as defaultLoadHookRegistry } from '../../runtime/load-hooks-or-exit.js';
|
|
4
6
|
/**
|
|
5
7
|
* Translate `pugi-tool-route command="..."` into the SDK's
|
|
6
8
|
* `EngineTaskKind`. `code` and `fix` pass through verbatim; `build`
|
|
@@ -83,13 +85,115 @@ function translateStreamEvent(event, names) {
|
|
|
83
85
|
*/
|
|
84
86
|
export function createEngineBridge(deps) {
|
|
85
87
|
const buildClient = deps.clientFactory ?? ((config) => new AnvilEngineLoopClient(config));
|
|
88
|
+
const loadMcp = deps.loadMcpRegistry ?? defaultLoadMcpRegistry;
|
|
89
|
+
const loadHooks = deps.loadHookRegistry ??
|
|
90
|
+
((opts) => defaultLoadHookRegistry(opts));
|
|
91
|
+
// PR A — per-bridge-instance caches. Spawning MCP child processes
|
|
92
|
+
// (multi-second startup for some servers) and parsing hook configuration
|
|
93
|
+
// are too expensive to repeat per turn AND have no per-turn state. We
|
|
94
|
+
// load once on the first call, reuse across turns. The MCP registry
|
|
95
|
+
// shutdown happens at process exit; a future PR will wire it to the
|
|
96
|
+
// REPL's exit hook so trusted child processes are reaped before the
|
|
97
|
+
// parent terminates.
|
|
98
|
+
//
|
|
99
|
+
// /triple-review P1 round 2: cache stored as in-flight Promise<T>
|
|
100
|
+
// instead of value + boolean flag. Two concurrent first-turn calls
|
|
101
|
+
// would otherwise both observe `mcpLoaded === false`, both spawn the
|
|
102
|
+
// expensive loader, and the second result would overwrite the first —
|
|
103
|
+
// leaking the first registry's child processes. Storing the promise
|
|
104
|
+
// means the second caller awaits the same in-flight load, gets the
|
|
105
|
+
// same instance, and there is no double-spawn. On loader failure the
|
|
106
|
+
// promise resolves to `undefined` (mirrors the value-cache fallback);
|
|
107
|
+
// a future retry on transient ENOENT would mint a NEW promise after
|
|
108
|
+
// explicit invalidation (out of scope for this PR — tracked as P2 fu).
|
|
109
|
+
let mcpRegistryPromise = null;
|
|
110
|
+
let hooksPromise = null;
|
|
111
|
+
const securityHooksParseFailed = { value: false };
|
|
112
|
+
async function resolveMcpRegistry(root) {
|
|
113
|
+
if (mcpRegistryPromise === null) {
|
|
114
|
+
mcpRegistryPromise = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
return await loadMcp(root);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// PR A — mirror cli.ts:6394 failure mode. A bad `.pugi/mcp.json`
|
|
120
|
+
// must not crash the REPL; the operator sees a stderr warning
|
|
121
|
+
// and continues without MCP tools. They can fix the file and
|
|
122
|
+
// the next REPL launch picks up the new registry.
|
|
123
|
+
const msg = error.message;
|
|
124
|
+
process.stderr.write(`pugi REPL engine bridge: MCP registry load failed — ${msg}. ` +
|
|
125
|
+
`Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
}
|
|
130
|
+
return mcpRegistryPromise;
|
|
131
|
+
}
|
|
132
|
+
async function resolveHooks(root, session) {
|
|
133
|
+
if (hooksPromise === null) {
|
|
134
|
+
hooksPromise = (async () => {
|
|
135
|
+
try {
|
|
136
|
+
const outcome = await loadHooks({
|
|
137
|
+
workspaceRoot: root,
|
|
138
|
+
session,
|
|
139
|
+
label: 'repl',
|
|
140
|
+
});
|
|
141
|
+
if (outcome.kind === 'parse-failure-refused') {
|
|
142
|
+
// /triple-review P1 round 2: hooks fail-open is a security
|
|
143
|
+
// regression vs cli.ts:6440 (hard-exit on parse failure).
|
|
144
|
+
// SECURITY: a workspace operator who configured a `PreToolUse
|
|
145
|
+
// onFailure: 'block'` rule that refuses bash containing `rm`
|
|
146
|
+
// loses that protection the moment the JSON file gets a
|
|
147
|
+
// typo. Stderr-only warning scrolls off the TUI.
|
|
148
|
+
//
|
|
149
|
+
// Mitigation: do NOT load hooks for this REPL session (so
|
|
150
|
+
// the operator's mental model — "I have hooks configured" —
|
|
151
|
+
// does not silently break), AND mark the bridge as "hooks
|
|
152
|
+
// parse failed" so every turn surfaces the warning until
|
|
153
|
+
// the file is fixed. The PUGI_HOOKS_BYPASS=1 env var
|
|
154
|
+
// (loadHookRegistryOrExit:97-107) is still the explicit
|
|
155
|
+
// escape hatch when the operator is mid-edit and acknowledges
|
|
156
|
+
// the risk.
|
|
157
|
+
securityHooksParseFailed.value = true;
|
|
158
|
+
process.stderr.write('pugi REPL engine bridge: SECURITY — hooks parse failed. ' +
|
|
159
|
+
'PreToolUse/PostToolUse rules are NOT active for this REPL ' +
|
|
160
|
+
'session. Fix .pugi/hooks.json and restart REPL, OR set ' +
|
|
161
|
+
'PUGI_HOOKS_BYPASS=1 to acknowledge and continue.\n');
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return outcome.hooks;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const msg = error.message;
|
|
168
|
+
process.stderr.write(`pugi REPL engine bridge: hooks loader threw — ${msg}. ` +
|
|
169
|
+
`Continuing without hooks.\n`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
}
|
|
174
|
+
return hooksPromise;
|
|
175
|
+
}
|
|
86
176
|
return async (input) => {
|
|
87
177
|
const root = deps.cwd();
|
|
88
178
|
const session = openSession(root);
|
|
89
179
|
const client = buildClient(deps.config);
|
|
180
|
+
const [cachedMcpRegistry, cachedHooks] = await Promise.all([
|
|
181
|
+
resolveMcpRegistry(root),
|
|
182
|
+
resolveHooks(root, session),
|
|
183
|
+
]);
|
|
184
|
+
if (securityHooksParseFailed.value) {
|
|
185
|
+
// /triple-review P1 round 2: re-emit the security warning on every
|
|
186
|
+
// bridged turn so the operator does not miss it after scrollback.
|
|
187
|
+
// Cheap one-liner, hard to miss in TUI status pane.
|
|
188
|
+
process.stderr.write('pugi REPL engine bridge: SECURITY reminder — hooks still NOT ' +
|
|
189
|
+
'loaded for this session (.pugi/hooks.json parse failure).\n');
|
|
190
|
+
}
|
|
90
191
|
const adapter = new NativePugiEngineAdapter({
|
|
91
192
|
client,
|
|
92
193
|
session,
|
|
194
|
+
...(cachedMcpRegistry ? { mcpRegistry: cachedMcpRegistry } : {}),
|
|
195
|
+
...(cachedHooks ? { hooks: cachedHooks } : {}),
|
|
196
|
+
...(deps.intensityProfile ? { intensityProfile: deps.intensityProfile } : {}),
|
|
93
197
|
});
|
|
94
198
|
// Per-call name map for matching `tool.end` -> `tool.start`. New
|
|
95
199
|
// every invocation so concurrent bridges never cross-pollute.
|
|
@@ -40,7 +40,8 @@ import { applyRewindMask } from '../checkpoint/rewinder.js';
|
|
|
40
40
|
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
41
41
|
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
42
42
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
43
|
-
import { extractToolRouteTags, } from './tool-route.js';
|
|
43
|
+
import { extractToolRouteTags, signatureForToolRoute, } from './tool-route.js';
|
|
44
|
+
import { personaSlugFor } from '../engine/prompts.js';
|
|
44
45
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
45
46
|
import { resolve as resolvePath } from 'node:path';
|
|
46
47
|
import { CancellationToken } from './cancellation.js';
|
|
@@ -2788,13 +2789,70 @@ export class ReplSession {
|
|
|
2788
2789
|
if (!this.streamHandle && !this.closed) {
|
|
2789
2790
|
this.openStream();
|
|
2790
2791
|
}
|
|
2792
|
+
// PR A (PUGI-538-FU) — REPL becomes a first-class engine
|
|
2793
|
+
// path. When the CLI REPL has an engine bridge wired the brief is
|
|
2794
|
+
// dispatched DIRECTLY to the inproc engine adapter via
|
|
2795
|
+
// `runEngineBridge` instead of POSTing к admin-api `/sessions/:id/brief`.
|
|
2796
|
+
//
|
|
2797
|
+
// Why this matters:
|
|
2798
|
+
// - The server-side bypass () had to fabricate a synthetic
|
|
2799
|
+
// `<pugi-tool-route>` envelope SSE event so the CLI parser would
|
|
2800
|
+
// fire `runEngineBridge`. That worked but cost one full HTTP
|
|
2801
|
+
// round-trip + SSE latency per turn — and required `cliVersion`
|
|
2802
|
+
// to thread correctly через the session-create + header pipe
|
|
2803
|
+
// (which broke in production: CEO smoke 2026-06-05 showed
|
|
2804
|
+
// `envelope=delegate` instead of `tool-route` because the version
|
|
2805
|
+
// header was missing on his customer-installed beta.95 client,
|
|
2806
|
+
// so the bypass branch never matched и the coordinator chat
|
|
2807
|
+
// ceremony ran anyway).
|
|
2808
|
+
// - Going direct removes that whole class of bug: the CLI knows
|
|
2809
|
+
// it is the CLI, it has the engine bridge in hand, it skips the
|
|
2810
|
+
// server entirely и calls the adapter inproc. Matches Claude
|
|
2811
|
+
// Code / Codex / Aider tools-first loop architecture.
|
|
2812
|
+
//
|
|
2813
|
+
// Personas survive: `personaSlugFor('code')` returns 'dev' (Hiroshi),
|
|
2814
|
+
// the engine adapter renders the persona system prompt + memory
|
|
2815
|
+
// recall just like `pugi code` direct CLI. The synthetic agent-tree
|
|
2816
|
+
// node inside `runEngineBridge` carries `personaName` so the TUI
|
|
2817
|
+
// shows "Hiroshi" the same way it did before.
|
|
2818
|
+
//
|
|
2819
|
+
// Server-side bypass от remains в place для non-CLI surfaces
|
|
2820
|
+
// (cabinet BFF, telegram bot) — they have no engine adapter wired,
|
|
2821
|
+
// so the server still needs to fabricate the dispatch on their behalf.
|
|
2822
|
+
//
|
|
2823
|
+
// Env opt-out: `PUGI_REPL_DIRECT_ENGINE=0` falls back к the HTTP
|
|
2824
|
+
// path for regression debugging. cliVersion presence is the CLI
|
|
2825
|
+
// signal — REPL embedded inside cabinet BFF mounts without that
|
|
2826
|
+
// field и continues к hit the server route.
|
|
2827
|
+
const useDirectEngine = this.options.engineBridge !== undefined &&
|
|
2828
|
+
typeof this.options.cliVersion === 'string' &&
|
|
2829
|
+
this.options.cliVersion.length > 0 &&
|
|
2830
|
+
(this.options.env ?? process.env).PUGI_REPL_DIRECT_ENGINE !== '0';
|
|
2791
2831
|
try {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2832
|
+
if (useDirectEngine) {
|
|
2833
|
+
const persona = personaSlugFor('code');
|
|
2834
|
+
const tag = {
|
|
2835
|
+
command: 'code',
|
|
2836
|
+
brief,
|
|
2837
|
+
persona,
|
|
2838
|
+
// Direct-dispatch tags do not flow through the parser, so the
|
|
2839
|
+
// start/end byte offsets are inapplicable. Keep `signatureForToolRoute`
|
|
2840
|
+
// so the seen-tag rolling set still de-dupes a brief that the
|
|
2841
|
+
// operator submits twice in a row by accident.
|
|
2842
|
+
signature: signatureForToolRoute('code', persona, brief),
|
|
2843
|
+
start: 0,
|
|
2844
|
+
end: 0,
|
|
2845
|
+
};
|
|
2846
|
+
await this.runEngineBridge(tag);
|
|
2847
|
+
}
|
|
2848
|
+
else {
|
|
2849
|
+
await this.options.transport.postBrief({
|
|
2850
|
+
apiUrl: this.options.apiUrl,
|
|
2851
|
+
apiKey: this.options.apiKey,
|
|
2852
|
+
sessionId,
|
|
2853
|
+
brief,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2798
2856
|
}
|
|
2799
2857
|
catch (error) {
|
|
2800
2858
|
this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.96');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -28,6 +28,7 @@ import { resolveTheme } from '../core/theme/state.js';
|
|
|
28
28
|
import { ReplSession, } from '../core/repl/session.js';
|
|
29
29
|
import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
|
|
30
30
|
import { createEngineBridge } from '../core/repl/engine-bridge.js';
|
|
31
|
+
import { profileFor, resolveIntensity } from '../core/engine/intensity.js';
|
|
31
32
|
import { SqliteSessionStore } from '../core/repl/store/index.js';
|
|
32
33
|
import { slugForCwd } from '../core/repl/history.js';
|
|
33
34
|
import { loadSettings } from '../core/settings.js';
|
|
@@ -162,12 +163,21 @@ export async function renderRepl(options) {
|
|
|
162
163
|
// the same UX as a pre-PUGI-538c CLI build.
|
|
163
164
|
let engineBridge;
|
|
164
165
|
try {
|
|
166
|
+
// PR A (2026-06-05): resolve intensity profile once at bridge
|
|
167
|
+
// construction time. The bridge stays pure and re-uses the resolved
|
|
168
|
+
// profile across every routed brief. `resolveIntensity` reads env
|
|
169
|
+
// (`PUGI_INTENSITY`) и settings.json; the REPL launch lifecycle is
|
|
170
|
+
// the natural binding point. A future `/intensity` REPL command
|
|
171
|
+
// will rebuild the bridge to swap the profile mid-session.
|
|
172
|
+
const intensityLevel = resolveIntensity({});
|
|
173
|
+
const intensityProfile = profileFor(intensityLevel);
|
|
165
174
|
engineBridge = createEngineBridge({
|
|
166
175
|
config: buildRuntimeConfig({
|
|
167
176
|
apiUrl: options.apiUrl,
|
|
168
177
|
apiKey: options.apiKey,
|
|
169
178
|
}),
|
|
170
179
|
cwd: () => process.cwd(),
|
|
180
|
+
intensityProfile,
|
|
171
181
|
});
|
|
172
182
|
}
|
|
173
183
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.96",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"which": "^6.0.0",
|
|
64
64
|
"zod": "^3.23.0",
|
|
65
65
|
"@pugi/personas": "0.1.2",
|
|
66
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
66
|
+
"@pugi/sdk": "0.1.0-beta.96"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@types/node": "^22.0.0",
|