@pugi/cli 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { classifyBash, listDestructivePatterns, } from './bash-classifier.js';
|
|
3
|
+
/**
|
|
4
|
+
* Map `PermissionAction.kind` to the `HookMatch.permission` taxonomy.
|
|
5
|
+
* The hook taxonomy uses `mcp`/`subagent` slots that the permission
|
|
6
|
+
* engine does not currently emit; `workflow` has no hook-side
|
|
7
|
+
* equivalent so it falls through as undefined (any matching hook with
|
|
8
|
+
* an explicit `permission` filter will not match).
|
|
9
|
+
*/
|
|
10
|
+
function toHookPermission(kind) {
|
|
11
|
+
switch (kind) {
|
|
12
|
+
case 'read':
|
|
13
|
+
case 'edit':
|
|
14
|
+
case 'bash':
|
|
15
|
+
case 'network':
|
|
16
|
+
case 'mcp':
|
|
17
|
+
return kind;
|
|
18
|
+
case 'workflow':
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fire the `PermissionRequest` hook for the given action and decision.
|
|
24
|
+
* Caller is the permission engine's user-facing surface — when the
|
|
25
|
+
* decision is `ask`, hooks can observe (and, with `onFailure: 'block'`,
|
|
26
|
+
* pre-empt) the prompt. The hook system does NOT replace the prompt —
|
|
27
|
+
* a `block` hook simply turns the decision into a refusal that the
|
|
28
|
+
* caller surfaces as a denial.
|
|
29
|
+
*
|
|
30
|
+
* Returns true when no blocking hook objected; false when at least one
|
|
31
|
+
* `onFailure: 'block'` hook returned a non-zero exit. Callers should
|
|
32
|
+
* treat false as "deny this action".
|
|
33
|
+
*/
|
|
34
|
+
export async function firePermissionRequestHook(action, decision, hooks, sessionId) {
|
|
35
|
+
hooks.resetBatch();
|
|
36
|
+
const ctx = {
|
|
37
|
+
sessionId,
|
|
38
|
+
event: 'PermissionRequest',
|
|
39
|
+
tool: action.tool,
|
|
40
|
+
permission: toHookPermission(action.kind),
|
|
41
|
+
path: action.kind === 'read' || action.kind === 'edit' ? action.target : undefined,
|
|
42
|
+
payload: { action, decision },
|
|
43
|
+
};
|
|
44
|
+
const matching = hooks.listMatching(ctx);
|
|
45
|
+
const results = await hooks.fire(ctx);
|
|
46
|
+
for (let i = 0; i < matching.length; i += 1) {
|
|
47
|
+
const hook = matching[i];
|
|
48
|
+
const result = results[i];
|
|
49
|
+
if (hook && result && hook.onFailure === 'block' && !result.ok) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
const protectedBasenames = new Set([
|
|
56
|
+
'.env',
|
|
57
|
+
'.npmrc',
|
|
58
|
+
'.yarnrc',
|
|
59
|
+
'.pypirc',
|
|
60
|
+
'.gitconfig',
|
|
61
|
+
'id_rsa',
|
|
62
|
+
'id_ed25519',
|
|
63
|
+
]);
|
|
64
|
+
const protectedSuffixes = ['.pem', '.key', '.crt', '.p12', '.dump', '.sql'];
|
|
65
|
+
/**
|
|
66
|
+
* Hard-deny list. The list of destructive substrings now lives in
|
|
67
|
+
* `bash-classifier.ts::DESTRUCTIVE_PATTERNS`; this function exposes
|
|
68
|
+
* it as a list for callers (doctor, debug tooling) that need to
|
|
69
|
+
* audit the rule set without re-running `classifyBash`.
|
|
70
|
+
*
|
|
71
|
+
* Code Reviewer P2 retro 2026-05-23: previously this list was
|
|
72
|
+
* duplicated here as `destructiveBashPatterns`. Sprint α5.2 moves it
|
|
73
|
+
* into the classifier so the permission engine and the doctor surface
|
|
74
|
+
* cannot drift.
|
|
75
|
+
*/
|
|
76
|
+
export function destructiveBashPatternsList() {
|
|
77
|
+
return listDestructivePatterns();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Class-aware bash permission decision. The matrix:
|
|
81
|
+
*
|
|
82
|
+
* | plan | ask | acceptEdits | auto | dontAsk | bypass
|
|
83
|
+
* read | allow| allow| allow | allow | allow | allow
|
|
84
|
+
* build_test | deny | ask | ask | allow | allow* | allow
|
|
85
|
+
* network | deny | ask | ask | ask | allow* | allow
|
|
86
|
+
* write_workspace | deny | ask | allow | allow | allow* | allow
|
|
87
|
+
* write_protected | deny | ask | ask | ask | deny | ask
|
|
88
|
+
* destructive | deny | deny | deny | deny | deny | deny**
|
|
89
|
+
* unknown | deny | ask | ask | ask | deny | ask
|
|
90
|
+
*
|
|
91
|
+
* * dontAsk allows non-destructive classes when no settings rule
|
|
92
|
+
* contradicts; the bare-mode policy is encoded in the table below.
|
|
93
|
+
* ** destructive can be unlocked ONLY when ALL three hold:
|
|
94
|
+
* - mode === 'bypassPermissions'
|
|
95
|
+
* - PUGI_DESTRUCTIVE_OVERRIDE === '1'
|
|
96
|
+
* - source === 'human'
|
|
97
|
+
* The agent loop never sets `source: 'human'`, so even a runaway
|
|
98
|
+
* agent in bypass mode cannot trigger a destructive deletion.
|
|
99
|
+
*/
|
|
100
|
+
export function evaluateBashPermission(cmd, mode, ctx) {
|
|
101
|
+
const classification = classifyBash(cmd, {
|
|
102
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
103
|
+
additionalDirectories: ctx.additionalDirectories,
|
|
104
|
+
});
|
|
105
|
+
return decisionForClass(classification, mode, ctx);
|
|
106
|
+
}
|
|
107
|
+
function decisionForClass(classification, mode, ctx) {
|
|
108
|
+
const { class: klass, reason, matched } = classification;
|
|
109
|
+
const explain = `${reason}${matched ? ` [matched=${matched}]` : ''}`;
|
|
110
|
+
if (klass === 'destructive') {
|
|
111
|
+
const overrideOk = mode === 'bypassPermissions' &&
|
|
112
|
+
ctx.source === 'human' &&
|
|
113
|
+
process.env.PUGI_DESTRUCTIVE_OVERRIDE === '1';
|
|
114
|
+
if (overrideOk) {
|
|
115
|
+
return {
|
|
116
|
+
decision: 'allow',
|
|
117
|
+
reason: `${explain}; destructive override engaged (PUGI_DESTRUCTIVE_OVERRIDE=1, human source, bypassPermissions)`,
|
|
118
|
+
source: 'mode.bypassPermissions.destructive_override',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
decision: 'deny',
|
|
123
|
+
reason: `${explain}; destructive class is always denied`,
|
|
124
|
+
source: 'classifier.destructive',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
switch (klass) {
|
|
128
|
+
case 'read':
|
|
129
|
+
return { decision: 'allow', reason: explain, source: `classifier.${klass}` };
|
|
130
|
+
case 'build_test':
|
|
131
|
+
return classAwareVerdict(mode, klass, explain, {
|
|
132
|
+
plan: 'deny',
|
|
133
|
+
ask: 'ask',
|
|
134
|
+
acceptEdits: 'ask',
|
|
135
|
+
auto: 'allow',
|
|
136
|
+
dontAsk: 'allow',
|
|
137
|
+
bypassPermissions: 'allow',
|
|
138
|
+
});
|
|
139
|
+
case 'network':
|
|
140
|
+
return classAwareVerdict(mode, klass, explain, {
|
|
141
|
+
plan: 'deny',
|
|
142
|
+
ask: 'ask',
|
|
143
|
+
acceptEdits: 'ask',
|
|
144
|
+
auto: 'ask',
|
|
145
|
+
dontAsk: 'allow',
|
|
146
|
+
bypassPermissions: 'allow',
|
|
147
|
+
});
|
|
148
|
+
case 'write_workspace':
|
|
149
|
+
return classAwareVerdict(mode, klass, explain, {
|
|
150
|
+
plan: 'deny',
|
|
151
|
+
ask: 'ask',
|
|
152
|
+
acceptEdits: 'allow',
|
|
153
|
+
auto: 'allow',
|
|
154
|
+
dontAsk: 'allow',
|
|
155
|
+
bypassPermissions: 'allow',
|
|
156
|
+
});
|
|
157
|
+
case 'write_protected':
|
|
158
|
+
return classAwareVerdict(mode, klass, explain, {
|
|
159
|
+
plan: 'deny',
|
|
160
|
+
ask: 'ask',
|
|
161
|
+
acceptEdits: 'ask',
|
|
162
|
+
auto: 'ask',
|
|
163
|
+
dontAsk: 'deny',
|
|
164
|
+
bypassPermissions: 'ask',
|
|
165
|
+
});
|
|
166
|
+
case 'unknown':
|
|
167
|
+
return classAwareVerdict(mode, klass, explain, {
|
|
168
|
+
plan: 'deny',
|
|
169
|
+
ask: 'ask',
|
|
170
|
+
acceptEdits: 'ask',
|
|
171
|
+
auto: 'ask',
|
|
172
|
+
dontAsk: 'deny',
|
|
173
|
+
bypassPermissions: 'ask',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function classAwareVerdict(mode, klass, reason, table) {
|
|
178
|
+
const verdict = table[mode];
|
|
179
|
+
const source = `classifier.${klass}.${mode}`;
|
|
180
|
+
switch (verdict) {
|
|
181
|
+
case 'allow':
|
|
182
|
+
return { decision: 'allow', reason, source };
|
|
183
|
+
case 'deny':
|
|
184
|
+
return { decision: 'deny', reason: `${reason}; ${mode} mode denies ${klass}`, source };
|
|
185
|
+
case 'ask':
|
|
186
|
+
return { decision: 'ask', reason, risk: riskForClass(klass) };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function riskForClass(klass) {
|
|
190
|
+
switch (klass) {
|
|
191
|
+
case 'read':
|
|
192
|
+
return 'low';
|
|
193
|
+
case 'build_test':
|
|
194
|
+
case 'write_workspace':
|
|
195
|
+
return 'medium';
|
|
196
|
+
case 'network':
|
|
197
|
+
case 'write_protected':
|
|
198
|
+
case 'unknown':
|
|
199
|
+
case 'destructive':
|
|
200
|
+
return 'high';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function decidePermission(action, settings, root) {
|
|
204
|
+
if (action.kind === 'bash') {
|
|
205
|
+
// Legacy callers (file-tools::bashTool, runtime/cli protected-file
|
|
206
|
+
// probe) still call `decidePermission` for bash. The class-aware
|
|
207
|
+
// engine is preferred; we route through it here so the hard-deny
|
|
208
|
+
// gate stays consistent regardless of entry point.
|
|
209
|
+
//
|
|
210
|
+
// Source defaults to 'agent' because every code path that calls
|
|
211
|
+
// `decidePermission({ kind: 'bash' })` today is reached through
|
|
212
|
+
// the engine loop. Direct human bash invocation goes through the
|
|
213
|
+
// new `evaluateBashPermission` with `source: 'human'`.
|
|
214
|
+
const decision = evaluateBashPermission(action.target, settings.permissions.mode, {
|
|
215
|
+
workspaceRoot: root,
|
|
216
|
+
additionalDirectories: [],
|
|
217
|
+
source: 'agent',
|
|
218
|
+
});
|
|
219
|
+
if (decision.decision === 'deny' && decision.source === 'classifier.destructive') {
|
|
220
|
+
// Locked-source identifier for the retro spec — the existing
|
|
221
|
+
// tests assert `source === 'hard_deny'`. Re-label so the
|
|
222
|
+
// semantic stays the same: a destructive command was blocked
|
|
223
|
+
// by the hard-deny gate.
|
|
224
|
+
return { ...decision, source: 'hard_deny' };
|
|
225
|
+
}
|
|
226
|
+
const signature = `${action.kind}:${action.target}`;
|
|
227
|
+
if (matchesAny(signature, settings.permissions.deny)) {
|
|
228
|
+
return { decision: 'deny', reason: `Denied by rule: ${signature}`, source: 'settings.deny' };
|
|
229
|
+
}
|
|
230
|
+
if (matchesAny(signature, settings.permissions.allow) && decision.decision !== 'deny') {
|
|
231
|
+
return { decision: 'allow', reason: `Allowed by rule: ${signature}`, source: 'settings.allow' };
|
|
232
|
+
}
|
|
233
|
+
return decision;
|
|
234
|
+
}
|
|
235
|
+
const protectedReason = protectedTargetReason(action, root);
|
|
236
|
+
if (protectedReason) {
|
|
237
|
+
return decisionForMode(settings.permissions.mode, protectedReason, 'protected_file', 'high');
|
|
238
|
+
}
|
|
239
|
+
const signature = `${action.kind}:${action.target}`;
|
|
240
|
+
if (matchesAny(signature, settings.permissions.deny)) {
|
|
241
|
+
return { decision: 'deny', reason: `Denied by rule: ${signature}`, source: 'settings.deny' };
|
|
242
|
+
}
|
|
243
|
+
if (matchesAny(signature, settings.permissions.notAutomatic) || matchesAny(signature, settings.workflow.notAutomatic)) {
|
|
244
|
+
return { decision: 'ask', reason: `Marked not automatic: ${signature}`, risk: 'medium' };
|
|
245
|
+
}
|
|
246
|
+
if (matchesAny(signature, settings.permissions.allow)) {
|
|
247
|
+
return { decision: 'allow', reason: `Allowed by rule: ${signature}`, source: 'settings.allow' };
|
|
248
|
+
}
|
|
249
|
+
return decisionForMode(settings.permissions.mode, `Default ${settings.permissions.mode} policy`, 'mode', riskForAction(action));
|
|
250
|
+
}
|
|
251
|
+
export function protectedTargetReason(action, root) {
|
|
252
|
+
if (action.kind !== 'edit' && action.kind !== 'read')
|
|
253
|
+
return null;
|
|
254
|
+
const target = resolve(root, action.target);
|
|
255
|
+
const name = basename(target);
|
|
256
|
+
if (protectedBasenames.has(name) || name.startsWith('.env.')) {
|
|
257
|
+
return `Protected file: ${action.target}`;
|
|
258
|
+
}
|
|
259
|
+
if (protectedSuffixes.some((suffix) => name.endsWith(suffix))) {
|
|
260
|
+
return `Protected file suffix: ${action.target}`;
|
|
261
|
+
}
|
|
262
|
+
if (target.includes('/.ssh/') || target.includes('/.gnupg/') || target.includes('/.aws/')) {
|
|
263
|
+
return `Protected credential path: ${action.target}`;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
function decisionForMode(mode, reason, source, risk) {
|
|
268
|
+
switch (mode) {
|
|
269
|
+
case 'plan':
|
|
270
|
+
return risk === 'low'
|
|
271
|
+
? { decision: 'allow', reason, source }
|
|
272
|
+
: { decision: 'deny', reason: `${reason}; plan mode blocks mutating actions`, source: 'mode.plan' };
|
|
273
|
+
case 'ask':
|
|
274
|
+
return { decision: 'ask', reason, risk };
|
|
275
|
+
case 'acceptEdits':
|
|
276
|
+
return risk === 'medium'
|
|
277
|
+
? { decision: 'allow', reason, source: 'mode.acceptEdits' }
|
|
278
|
+
: { decision: 'ask', reason, risk };
|
|
279
|
+
case 'auto':
|
|
280
|
+
return risk === 'high' ? { decision: 'ask', reason, risk } : { decision: 'allow', reason, source: 'mode.auto' };
|
|
281
|
+
case 'dontAsk':
|
|
282
|
+
return risk === 'high'
|
|
283
|
+
? { decision: 'deny', reason: `${reason}; dontAsk denies high-risk actions`, source: 'mode.dontAsk' }
|
|
284
|
+
: { decision: 'allow', reason, source: 'mode.dontAsk' };
|
|
285
|
+
case 'bypassPermissions':
|
|
286
|
+
return { decision: 'allow', reason, source: 'mode.bypassPermissions' };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function riskForAction(action) {
|
|
290
|
+
if (action.kind === 'read')
|
|
291
|
+
return 'low';
|
|
292
|
+
if (action.kind === 'edit')
|
|
293
|
+
return 'medium';
|
|
294
|
+
if (action.kind === 'bash')
|
|
295
|
+
return 'high';
|
|
296
|
+
if (action.kind === 'workflow')
|
|
297
|
+
return 'medium';
|
|
298
|
+
return 'medium';
|
|
299
|
+
}
|
|
300
|
+
function matchesAny(value, rules) {
|
|
301
|
+
return rules.some((rule) => {
|
|
302
|
+
if (rule === value)
|
|
303
|
+
return true;
|
|
304
|
+
if (rule.endsWith('*'))
|
|
305
|
+
return value.startsWith(rule.slice(0, -1));
|
|
306
|
+
return false;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=permission.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side concurrent-subagent cap - Sprint α5.7 (ADR-0056
|
|
3
|
+
* acceptance #6, Mac safety memo
|
|
4
|
+
* `feedback_max_3_parallel_agents_mac_safety.md`).
|
|
5
|
+
*
|
|
6
|
+
* Mac-safety memo caps interactive concurrency at 3 active subagents
|
|
7
|
+
* per workstation. The REPL has its own client-side gate that mirrors
|
|
8
|
+
* the global rule for one workstation worth of dispatches:
|
|
9
|
+
*
|
|
10
|
+
* - active count >= 3 → soft warning (operator may still dispatch).
|
|
11
|
+
* - active count >= 5 → hard block unless the operator opted in via
|
|
12
|
+
* PUGI_FORCE_PARALLEL=1.
|
|
13
|
+
*
|
|
14
|
+
* The gate is pure: callers pass the current active count and we return
|
|
15
|
+
* a verdict. The REPL session module reads the dispatcher event stream,
|
|
16
|
+
* tracks active dispatches, and consults this function before
|
|
17
|
+
* forwarding `/brief` to the controller.
|
|
18
|
+
*
|
|
19
|
+
* The hard cap matches the absolute ceiling cited in the Mac memo (3
|
|
20
|
+
* brand-recommended, 5 ABSOLUTE max with the env-flag escape hatch).
|
|
21
|
+
* Bumping either threshold is a memo update, not a code-only change.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Soft warning threshold. At this many active subagents the REPL
|
|
25
|
+
* surfaces a non-blocking nudge. The operator can confirm and proceed.
|
|
26
|
+
*/
|
|
27
|
+
export const CAP_WARNING_THRESHOLD = 3;
|
|
28
|
+
/**
|
|
29
|
+
* Hard block threshold. At this many active subagents the REPL refuses
|
|
30
|
+
* the new dispatch unless `PUGI_FORCE_PARALLEL=1` is in the environment.
|
|
31
|
+
*/
|
|
32
|
+
export const CAP_HARD_BLOCK_THRESHOLD = 5;
|
|
33
|
+
/**
|
|
34
|
+
* Env flag operators set to override the hard cap. Documented in
|
|
35
|
+
* `pugi doctor` output and in the Mac safety memo. Set this to `1`,
|
|
36
|
+
* `true`, or `yes` to bypass. Anything else (including unset) leaves
|
|
37
|
+
* the cap enforced.
|
|
38
|
+
*/
|
|
39
|
+
export const FORCE_PARALLEL_ENV_VAR = 'PUGI_FORCE_PARALLEL';
|
|
40
|
+
/**
|
|
41
|
+
* Compute the cap verdict given the current active subagent count and
|
|
42
|
+
* the operator's environment.
|
|
43
|
+
*
|
|
44
|
+
* The function is pure (no global state, no IO). Callers must pass the
|
|
45
|
+
* env they want considered - production callers pass process.env, tests
|
|
46
|
+
* pass a fixture so the gate is deterministic.
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateCap(input) {
|
|
49
|
+
const active = Math.max(0, Math.floor(input.active));
|
|
50
|
+
const env = input.env ?? {};
|
|
51
|
+
const forceParallel = isForceParallelSet(env);
|
|
52
|
+
if (active >= CAP_HARD_BLOCK_THRESHOLD) {
|
|
53
|
+
if (forceParallel) {
|
|
54
|
+
return { kind: 'override', active, envVar: FORCE_PARALLEL_ENV_VAR };
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'block', active, envVar: FORCE_PARALLEL_ENV_VAR };
|
|
57
|
+
}
|
|
58
|
+
if (active >= CAP_WARNING_THRESHOLD) {
|
|
59
|
+
return { kind: 'warn', active };
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'allow' };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Render a one-line operator nudge for a non-allow verdict. The REPL
|
|
65
|
+
* appends this line to the conversation pane on its own row so the
|
|
66
|
+
* cyan colour token survives Ink's whitespace collapsing.
|
|
67
|
+
*
|
|
68
|
+
* Brand voice: power words `on watch`, `workforce`. No em dash, no
|
|
69
|
+
* emoji. Capacity rather than `agents` to avoid colliding with the
|
|
70
|
+
* `/agents` command name in the same frame.
|
|
71
|
+
*/
|
|
72
|
+
export function describeVerdict(verdict) {
|
|
73
|
+
switch (verdict.kind) {
|
|
74
|
+
case 'allow':
|
|
75
|
+
return '';
|
|
76
|
+
case 'warn':
|
|
77
|
+
return `Capacity nudge: ${verdict.active} agents already on watch - keep an eye on workstation load.`;
|
|
78
|
+
case 'block':
|
|
79
|
+
return `Capacity block: ${verdict.active} agents on watch is the absolute ceiling. Set ${verdict.envVar}=1 to override.`;
|
|
80
|
+
case 'override':
|
|
81
|
+
return `Capacity cap bypassed (${verdict.envVar}=1) - ${verdict.active} agents already on watch.`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function isForceParallelSet(env) {
|
|
85
|
+
const raw = env[FORCE_PARALLEL_ENV_VAR];
|
|
86
|
+
if (typeof raw !== 'string')
|
|
87
|
+
return false;
|
|
88
|
+
const normalized = raw.trim().toLowerCase();
|
|
89
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=cap-warning.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort clipboard READ helper - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the clipboard WRITE helper but for the opposite
|
|
5
|
+
* direction. Powers Ctrl+V paste in the REPL input box: when the
|
|
6
|
+
* operator presses Ctrl+V we spawn the platform's paste binary
|
|
7
|
+
* (pbpaste / wl-paste / xclip -o / PowerShell Get-Clipboard) and
|
|
8
|
+
* insert the result at the cursor.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - Returns `{ text }` when the helper exited 0 with non-empty
|
|
12
|
+
* stdout. Trailing single newline is stripped (clipboard
|
|
13
|
+
* contents authored by the operator rarely include the trailing
|
|
14
|
+
* LF the paste helpers append).
|
|
15
|
+
* - Returns `{ text: null }` on any failure: missing binary,
|
|
16
|
+
* non-zero exit, no $DISPLAY on headless Linux, EAGAIN.
|
|
17
|
+
* - Never throws. Caller falls back to "Ctrl+V not available -
|
|
18
|
+
* try right-click paste" hint.
|
|
19
|
+
* - Targets node >= 20.
|
|
20
|
+
*
|
|
21
|
+
* Implementation note: we do NOT optimistically run all three Linux
|
|
22
|
+
* helpers in parallel - the cost of spawning xclip when wl-copy
|
|
23
|
+
* already produced text is small but visible (50ms+ each on cold
|
|
24
|
+
* cache). Ordering: WAYLAND_DISPLAY → wl-paste; otherwise xclip first.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
export async function readClipboard(deps = {}) {
|
|
28
|
+
const platform = deps.platform ?? process.platform;
|
|
29
|
+
const spawnRead = deps.spawnRead ?? defaultSpawnRead;
|
|
30
|
+
const env = deps.env ?? process.env;
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
const text = await spawnRead('pbpaste', []);
|
|
33
|
+
return { text: normalise(text) };
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
// PowerShell is the universally available path. Get-Clipboard
|
|
37
|
+
// emits UTF-16 by default; the -Raw flag preserves newlines and
|
|
38
|
+
// returns one string.
|
|
39
|
+
const text = await spawnRead('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']);
|
|
40
|
+
return { text: normalise(text) };
|
|
41
|
+
}
|
|
42
|
+
if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
|
|
43
|
+
const order = env.WAYLAND_DISPLAY
|
|
44
|
+
? ['wl-paste', 'xclip', 'xsel']
|
|
45
|
+
: ['xclip', 'wl-paste', 'xsel'];
|
|
46
|
+
for (const tool of order) {
|
|
47
|
+
const args = tool === 'xclip'
|
|
48
|
+
? ['-selection', 'clipboard', '-o']
|
|
49
|
+
: tool === 'xsel'
|
|
50
|
+
? ['--clipboard', '--output']
|
|
51
|
+
: ['--no-newline'];
|
|
52
|
+
const text = await spawnRead(tool, args);
|
|
53
|
+
if (text !== null && text.length > 0) {
|
|
54
|
+
return { text: normalise(text) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { text: null };
|
|
58
|
+
}
|
|
59
|
+
return { text: null };
|
|
60
|
+
}
|
|
61
|
+
function normalise(raw) {
|
|
62
|
+
if (raw === null)
|
|
63
|
+
return null;
|
|
64
|
+
if (raw.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
// Strip a single trailing LF the helpers often append. Multi-line
|
|
67
|
+
// pastes preserve interior newlines.
|
|
68
|
+
return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Hard cap on clipboard payload size. The paste helper streams stdout
|
|
72
|
+
* with no upper bound by default, so a pathologically large clipboard
|
|
73
|
+
* (e.g. a hostile actor's 500 MiB file URL list, or an accidental copy
|
|
74
|
+
* of a large binary) would OOM the CLI process if we kept appending.
|
|
75
|
+
* 1 MiB is plenty for any realistic prompt + code snippet paste and
|
|
76
|
+
* still leaves room in V8's default young-generation heap.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_CLIPBOARD_BYTES = 1024 * 1024; // 1 MiB
|
|
79
|
+
function defaultSpawnRead(cmd, args) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
let overflow = false;
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
const chunks = [];
|
|
85
|
+
const settle = (value) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
resolve(value);
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
const options = {
|
|
93
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
94
|
+
shell: false,
|
|
95
|
+
};
|
|
96
|
+
const child = spawn(cmd, args.slice(), options);
|
|
97
|
+
child.on('error', () => settle(null));
|
|
98
|
+
child.stdout?.on('data', (b) => {
|
|
99
|
+
if (overflow)
|
|
100
|
+
return;
|
|
101
|
+
totalBytes += b.length;
|
|
102
|
+
if (totalBytes > MAX_CLIPBOARD_BYTES) {
|
|
103
|
+
overflow = true;
|
|
104
|
+
try {
|
|
105
|
+
child.kill('SIGTERM');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best effort; close handler still settles below.
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(b);
|
|
113
|
+
});
|
|
114
|
+
child.on('close', (code) => {
|
|
115
|
+
if (overflow)
|
|
116
|
+
return settle(null);
|
|
117
|
+
if (code !== 0)
|
|
118
|
+
return settle(null);
|
|
119
|
+
const buf = Buffer.concat(chunks);
|
|
120
|
+
settle(buf.toString('utf8'));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
settle(null);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* Bracketed-paste mode helpers */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
/**
|
|
132
|
+
* Modern terminals (iTerm2, Alacritty, GNOME Terminal, kitty,
|
|
133
|
+
* Windows Terminal) bracket pasted text with `ESC[200~ ... ESC[201~`
|
|
134
|
+
* when bracketed-paste mode is enabled. The input box can detect
|
|
135
|
+
* these markers and disable newline-as-submit during the paste burst
|
|
136
|
+
* so a multi-line paste does not fire `onSubmit` mid-paste.
|
|
137
|
+
*
|
|
138
|
+
* The constants here are exported so the input box and the unit
|
|
139
|
+
* test can share the byte sequences without re-implementing them.
|
|
140
|
+
*/
|
|
141
|
+
export const PASTE_START = '\x1b[200~';
|
|
142
|
+
export const PASTE_END = '\x1b[201~';
|
|
143
|
+
export const CTRL_V = '\x16';
|
|
144
|
+
export function classifyChunk(chunk, inPaste) {
|
|
145
|
+
if (inPaste) {
|
|
146
|
+
const endIdx = chunk.indexOf(PASTE_END);
|
|
147
|
+
if (endIdx === -1) {
|
|
148
|
+
return { kind: 'paste-cont', textInPaste: chunk };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kind: 'paste-end',
|
|
152
|
+
textInPaste: chunk.slice(0, endIdx),
|
|
153
|
+
textAfter: chunk.slice(endIdx + PASTE_END.length),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const startIdx = chunk.indexOf(PASTE_START);
|
|
157
|
+
if (startIdx === -1) {
|
|
158
|
+
return { kind: 'plain', text: chunk };
|
|
159
|
+
}
|
|
160
|
+
const afterStart = chunk.slice(startIdx + PASTE_START.length);
|
|
161
|
+
const endIdx = afterStart.indexOf(PASTE_END);
|
|
162
|
+
if (endIdx === -1) {
|
|
163
|
+
return {
|
|
164
|
+
kind: 'paste-start',
|
|
165
|
+
textBefore: chunk.slice(0, startIdx),
|
|
166
|
+
textInPaste: afterStart,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
kind: 'paste-only',
|
|
171
|
+
text: afterStart.slice(0, endIdx),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=clipboard-read.js.map
|