@pugi/cli 0.1.0-beta.18 → 0.1.0-beta.19
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/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/tool-bridge.js +153 -14
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +227 -9
- package/dist/core/repl/slash-commands.js +58 -4
- package/dist/runtime/cli.js +129 -0
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +12 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace permission-mode session state — Leak L6.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `.pugi/session.json` under the workspace root. The
|
|
5
|
+
* file is read on first `getCurrentMode()` call (cached for the
|
|
6
|
+
* process lifetime) and written atomically via tmp+rename on
|
|
7
|
+
* `setCurrentMode()` so a kill mid-write does not corrupt the JSON.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order for the effective mode on a fresh process:
|
|
10
|
+
* 1. CLI flag (`pugi --mode plan`) — passed via `resolveMode` arg;
|
|
11
|
+
* not read from disk here.
|
|
12
|
+
* 2. Workspace session state — `<root>/.pugi/session.json` field
|
|
13
|
+
* `permissionMode`.
|
|
14
|
+
* 3. Global config — `~/.pugi/config.json` field
|
|
15
|
+
* `defaultPermissionMode`.
|
|
16
|
+
* 4. Hard default `ask`.
|
|
17
|
+
*
|
|
18
|
+
* This module owns layers 2 + 3. The CLI arg parser owns layer 1; both
|
|
19
|
+
* funnel into `resolveMode()` which performs the merge.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, resolve } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { DEFAULT_PERMISSION_MODE, isPermissionMode, parsePermissionMode, } from './mode.js';
|
|
26
|
+
const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
|
|
27
|
+
const sessionStateSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
permissionMode: permissionModeEnum.optional(),
|
|
30
|
+
})
|
|
31
|
+
.partial()
|
|
32
|
+
.passthrough();
|
|
33
|
+
const globalConfigSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
defaultPermissionMode: permissionModeEnum.optional(),
|
|
36
|
+
})
|
|
37
|
+
.partial()
|
|
38
|
+
.passthrough();
|
|
39
|
+
const SESSION_FILE = '.pugi/session.json';
|
|
40
|
+
/**
|
|
41
|
+
* Return the path to the workspace session-state file.
|
|
42
|
+
*/
|
|
43
|
+
export function sessionStatePath(workspaceRoot) {
|
|
44
|
+
return resolve(workspaceRoot, SESSION_FILE);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Return the path to the user-global config file. Uses HOME env when
|
|
48
|
+
* present (test fixtures, CI) so we never accidentally hit the real
|
|
49
|
+
* user-global file in spec runs.
|
|
50
|
+
*/
|
|
51
|
+
export function globalConfigPath(homeDir = homedir()) {
|
|
52
|
+
return resolve(homeDir, '.pugi/config.json');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read the workspace's saved permission mode. Returns null when the
|
|
56
|
+
* file is absent OR the field is unset; the caller layers in CLI + env
|
|
57
|
+
* + global config defaults to produce the effective mode.
|
|
58
|
+
*
|
|
59
|
+
* Never throws on JSON parse / schema errors — a malformed session
|
|
60
|
+
* file should not break the gate. The defensive `try/catch` returns
|
|
61
|
+
* null and lets the caller fall through to the next layer.
|
|
62
|
+
*/
|
|
63
|
+
export function getCurrentMode(workspaceRoot) {
|
|
64
|
+
const path = sessionStatePath(workspaceRoot);
|
|
65
|
+
if (!existsSync(path))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(path, 'utf8');
|
|
69
|
+
const parsed = sessionStateSchema.parse(JSON.parse(raw));
|
|
70
|
+
return isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Persist the workspace's permission mode. Creates the `.pugi/` dir
|
|
78
|
+
* when missing; preserves any unrelated keys in the file (passthrough
|
|
79
|
+
* schema). Atomic tmp+rename so a kill mid-write does not corrupt the
|
|
80
|
+
* JSON.
|
|
81
|
+
*/
|
|
82
|
+
export function setCurrentMode(workspaceRoot, mode) {
|
|
83
|
+
const path = sessionStatePath(workspaceRoot);
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
const existing = existsSync(path)
|
|
86
|
+
? safeParseObject(readFileSync(path, 'utf8'))
|
|
87
|
+
: {};
|
|
88
|
+
const next = { ...existing, permissionMode: mode };
|
|
89
|
+
const tmpPath = `${path}.tmp`;
|
|
90
|
+
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
91
|
+
renameSync(tmpPath, path);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read `~/.pugi/config.json::defaultPermissionMode`. Returns null when
|
|
95
|
+
* the file is absent / the field is unset; same defensive behaviour
|
|
96
|
+
* as `getCurrentMode` — a malformed global config never breaks the gate.
|
|
97
|
+
*/
|
|
98
|
+
export function getGlobalDefaultMode(homeDir = homedir()) {
|
|
99
|
+
const path = globalConfigPath(homeDir);
|
|
100
|
+
if (!existsSync(path))
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
const raw = readFileSync(path, 'utf8');
|
|
104
|
+
const parsed = globalConfigSchema.parse(JSON.parse(raw));
|
|
105
|
+
return isPermissionMode(parsed.defaultPermissionMode) ? parsed.defaultPermissionMode : null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Persist `~/.pugi/config.json::defaultPermissionMode`. Used by the
|
|
113
|
+
* `/permissions <mode> --persist` flow so a future fresh session
|
|
114
|
+
* defaults to the same mode without an explicit `--mode` flag.
|
|
115
|
+
*/
|
|
116
|
+
export function setGlobalDefaultMode(mode, homeDir = homedir()) {
|
|
117
|
+
const path = globalConfigPath(homeDir);
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
const existing = existsSync(path)
|
|
120
|
+
? safeParseObject(readFileSync(path, 'utf8'))
|
|
121
|
+
: {};
|
|
122
|
+
const next = { ...existing, defaultPermissionMode: mode };
|
|
123
|
+
const tmpPath = `${path}.tmp`;
|
|
124
|
+
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
125
|
+
renameSync(tmpPath, path);
|
|
126
|
+
}
|
|
127
|
+
export function resolveMode(options) {
|
|
128
|
+
if (options.cliFlag) {
|
|
129
|
+
const flag = parsePermissionMode(options.cliFlag);
|
|
130
|
+
if (flag)
|
|
131
|
+
return flag;
|
|
132
|
+
}
|
|
133
|
+
const workspace = getCurrentMode(options.workspaceRoot);
|
|
134
|
+
if (workspace)
|
|
135
|
+
return workspace;
|
|
136
|
+
const global = getGlobalDefaultMode(options.homeDir);
|
|
137
|
+
if (global)
|
|
138
|
+
return global;
|
|
139
|
+
return DEFAULT_PERMISSION_MODE;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Defensive helper — parse JSON to an object; non-object payload (top-
|
|
143
|
+
* level array, primitive) collapses to an empty object so the merge
|
|
144
|
+
* doesn't surface a TypeError. The `setCurrentMode` / `setGlobalDefaultMode`
|
|
145
|
+
* helpers only write objects, so a non-object existing file is corrupted
|
|
146
|
+
* and we explicitly reset it rather than appending into a non-object.
|
|
147
|
+
*/
|
|
148
|
+
function safeParseObject(raw) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool side-effect classification — Leak L6.
|
|
3
|
+
*
|
|
4
|
+
* Three classes drive the canonical 4-mode permission gate:
|
|
5
|
+
*
|
|
6
|
+
* - `read` — observe-only. Plan mode allows; ask still prompts;
|
|
7
|
+
* allow + bypass execute silently. Examples: read,
|
|
8
|
+
* grep, glob, web_fetch, web_search, skills_list.
|
|
9
|
+
* - `write` — mutates workspace, journal, or operator screen with
|
|
10
|
+
* visible side effects. Plan mode refuses; ask prompts;
|
|
11
|
+
* allow + bypass execute. Examples: write, edit, bash,
|
|
12
|
+
* multi_edit, task_*. `ask_user_question` is also
|
|
13
|
+
* classed as `write` because it interrupts the
|
|
14
|
+
* dispatcher's flow control and demands operator
|
|
15
|
+
* attention — plan mode should not prompt operators.
|
|
16
|
+
* - `dispatch` — spawns a child subagent or off-tree task. Plan mode
|
|
17
|
+
* refuses (a write-capable child violates plan-mode's
|
|
18
|
+
* read-only contract); ask prompts; allow + bypass
|
|
19
|
+
* execute. Example: `agent`.
|
|
20
|
+
*
|
|
21
|
+
* Unknown tool names default to `write` — deny-first safety. A stale
|
|
22
|
+
* schema entry that the gate has not been told about should not silently
|
|
23
|
+
* pass in plan mode just because the gate doesn't recognise it.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Closed map of every built-in tool name -> side-effect class. The
|
|
27
|
+
* source of truth for the four standard modes; mirrored against the
|
|
28
|
+
* `WIRED_TOOLS` set in `core/engine/tool-bridge.ts` so an unrecognised
|
|
29
|
+
* tool surfaces as the safe deny-first `write` default.
|
|
30
|
+
*
|
|
31
|
+
* MCP tools follow the `mcp__<server>__<tool>` namespace and are
|
|
32
|
+
* uniformly classed via `getToolClass` because per-tool annotations are
|
|
33
|
+
* not yet a part of the MCP spec — treating them as `write` is the
|
|
34
|
+
* conservative default until server-side metadata is trustworthy.
|
|
35
|
+
*/
|
|
36
|
+
const BUILT_IN_TOOL_CLASSES = Object.freeze({
|
|
37
|
+
// Read-only observations.
|
|
38
|
+
read: 'read',
|
|
39
|
+
grep: 'read',
|
|
40
|
+
glob: 'read',
|
|
41
|
+
ls: 'read',
|
|
42
|
+
search: 'read',
|
|
43
|
+
web_fetch: 'read',
|
|
44
|
+
web_search: 'read',
|
|
45
|
+
file_cache_check: 'read',
|
|
46
|
+
skills_list: 'read',
|
|
47
|
+
skill: 'read',
|
|
48
|
+
task_get: 'read',
|
|
49
|
+
task_list: 'read',
|
|
50
|
+
// Mutating actions.
|
|
51
|
+
write: 'write',
|
|
52
|
+
edit: 'write',
|
|
53
|
+
multi_edit: 'write',
|
|
54
|
+
bash: 'write',
|
|
55
|
+
task_create: 'write',
|
|
56
|
+
task_update: 'write',
|
|
57
|
+
todo_write: 'write',
|
|
58
|
+
// `ask_user_question` halts the loop and demands operator attention.
|
|
59
|
+
// Plan mode should not interrupt — class as write so the gate refuses
|
|
60
|
+
// it in plan mode but ask + allow + bypass execute normally.
|
|
61
|
+
ask_user_question: 'write',
|
|
62
|
+
// Dispatch — spawn a child agent. Refused in plan mode regardless of
|
|
63
|
+
// the child's role tier (the engine adapter applies role-based
|
|
64
|
+
// capability filtering, but the gate refuses dispatch up front so a
|
|
65
|
+
// plan-mode session cannot leak a writeable child).
|
|
66
|
+
agent: 'dispatch',
|
|
67
|
+
pugi_delegate: 'dispatch',
|
|
68
|
+
sub_agent_spawn: 'dispatch',
|
|
69
|
+
});
|
|
70
|
+
const MCP_TOOL_PREFIX = 'mcp__';
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the class for a tool name. Unknown names default to `write`
|
|
73
|
+
* (deny-first). MCP tools (any name prefixed with `mcp__`) default to
|
|
74
|
+
* `write` for the same conservative reason — the MCP spec lacks
|
|
75
|
+
* per-tool annotations today.
|
|
76
|
+
*/
|
|
77
|
+
export function getToolClass(toolName) {
|
|
78
|
+
const builtIn = BUILT_IN_TOOL_CLASSES[toolName];
|
|
79
|
+
if (builtIn)
|
|
80
|
+
return builtIn;
|
|
81
|
+
if (toolName.startsWith(MCP_TOOL_PREFIX))
|
|
82
|
+
return 'write';
|
|
83
|
+
return 'write';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Expose the built-in class map for diagnostic surfaces (`pugi doctor`,
|
|
87
|
+
* test fixtures). Caller MUST NOT mutate — the object is already frozen
|
|
88
|
+
* so any attempt throws in strict mode.
|
|
89
|
+
*/
|
|
90
|
+
export function listBuiltInToolClasses() {
|
|
91
|
+
return BUILT_IN_TOOL_CLASSES;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=tool-class.js.map
|
|
@@ -34,6 +34,9 @@ import { parseSlashCommand } from './slash-commands.js';
|
|
|
34
34
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
35
35
|
import { loadSettings } from '../settings.js';
|
|
36
36
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
37
|
+
import { applyCompactMask } from '../compact/buffer-rewriter.js';
|
|
38
|
+
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
39
|
+
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
37
40
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
38
41
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
39
42
|
import { resolve as resolvePath } from 'node:path';
|
|
@@ -638,7 +641,7 @@ export class ReplSession {
|
|
|
638
641
|
return verdict;
|
|
639
642
|
}
|
|
640
643
|
case 'status': {
|
|
641
|
-
this.dispatchStatus();
|
|
644
|
+
await this.dispatchStatus();
|
|
642
645
|
return verdict;
|
|
643
646
|
}
|
|
644
647
|
case 'consensus': {
|
|
@@ -799,12 +802,89 @@ export class ReplSession {
|
|
|
799
802
|
}
|
|
800
803
|
return verdict;
|
|
801
804
|
}
|
|
805
|
+
case 'permissions': {
|
|
806
|
+
// Leak L6: handle the `/permissions [mode] [--persist]` flow.
|
|
807
|
+
// The session module forwards to the runtime helper so the
|
|
808
|
+
// workspace + global-config writes share one code path with
|
|
809
|
+
// the CLI's top-level `--mode` resolution. The dynamic import
|
|
810
|
+
// keeps the dispatcher free of a session.ts -> runtime/cli.ts
|
|
811
|
+
// cycle.
|
|
812
|
+
try {
|
|
813
|
+
const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
|
|
814
|
+
const lines = [];
|
|
815
|
+
await runPermissionsCommand(verdict, {
|
|
816
|
+
workspaceRoot: process.cwd(),
|
|
817
|
+
writeOutput: (line) => {
|
|
818
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
819
|
+
if (trimmed.length > 0)
|
|
820
|
+
lines.push(trimmed);
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
for (const line of lines)
|
|
824
|
+
this.appendSystemLine(line);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
828
|
+
this.appendSystemLine(`/permissions failed: ${message}`);
|
|
829
|
+
}
|
|
830
|
+
return verdict;
|
|
831
|
+
}
|
|
832
|
+
case 'compact': {
|
|
833
|
+
// Leak L8 (2026-05-27): /compact summarises older turns and
|
|
834
|
+
// appends a boundary marker. We forward to the same runner the
|
|
835
|
+
// top-level `pugi compact` command uses so the surface stays
|
|
836
|
+
// single-sourced. The session module owns the in-memory
|
|
837
|
+
// transcript echo (system line + banner row) so the operator
|
|
838
|
+
// sees the marker land without a fresh REPL bootstrap.
|
|
839
|
+
await this.dispatchCompact('manual');
|
|
840
|
+
return verdict;
|
|
841
|
+
}
|
|
802
842
|
case 'stub': {
|
|
803
843
|
this.appendSystemLine(verdict.message);
|
|
804
844
|
return verdict;
|
|
805
845
|
}
|
|
806
846
|
}
|
|
807
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* Leak L8 (2026-05-27): drive the `/compact` flow from inside the
|
|
850
|
+
* REPL. Reuses the standalone runner so the wire shape + reason
|
|
851
|
+
* codes stay single-sourced. The result is echoed into the
|
|
852
|
+
* transcript as a system line; on success the operator sees the
|
|
853
|
+
* banner sentinel on next render.
|
|
854
|
+
*
|
|
855
|
+
* `trigger='manual'` for explicit `/compact` invocations;
|
|
856
|
+
* `trigger='auto'` for the threshold gate. The runner records the
|
|
857
|
+
* trigger in the marker payload so the banner can distinguish them.
|
|
858
|
+
*/
|
|
859
|
+
async dispatchCompact(trigger) {
|
|
860
|
+
if (!this.store || !this.localSessionId) {
|
|
861
|
+
this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
const { runCompactCommand } = await import('../../runtime/commands/compact.js');
|
|
866
|
+
const result = await runCompactCommand([], {
|
|
867
|
+
workspaceRoot: process.cwd(),
|
|
868
|
+
sessionId: this.localSessionId,
|
|
869
|
+
store: this.store,
|
|
870
|
+
trigger,
|
|
871
|
+
writeOutput: (_payload, text) => {
|
|
872
|
+
if (text.length > 0)
|
|
873
|
+
this.appendSystemLine(text);
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
if (result.status === 'compacted') {
|
|
877
|
+
// Echo a visible separator into the transcript so the operator
|
|
878
|
+
// immediately sees where the compaction landed. The Ink banner
|
|
879
|
+
// renders the row when the session reloads / resumes.
|
|
880
|
+
this.appendSystemLine(`─── context compacted (${result.turnsBefore} turns → 1 summary, ${trigger}) ───`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
885
|
+
this.appendSystemLine(`/compact failed: ${message}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
808
888
|
/**
|
|
809
889
|
* In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
|
|
810
890
|
* doc + the current mode banner inline. The current mode is fetched
|
|
@@ -1172,13 +1252,70 @@ export class ReplSession {
|
|
|
1172
1252
|
clearTimeout(timer);
|
|
1173
1253
|
}
|
|
1174
1254
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1255
|
+
/**
|
|
1256
|
+
* In-REPL `/status` — Leak L34 (2026-05-27). Surfaces the full
|
|
1257
|
+
* session snapshot (id + age, cwd, permission mode, CLI version,
|
|
1258
|
+
* tokens, dispatches, last cmd, compact boundaries, auth identity,
|
|
1259
|
+
* connection) by delegating к the same `runStatusCommand` the
|
|
1260
|
+
* top-level `pugi status` shell uses. Live REPL state (session
|
|
1261
|
+
* id, token totals, last operator command) flows in through the
|
|
1262
|
+
* context so the slash variant shows MORE than the shell path.
|
|
1263
|
+
*
|
|
1264
|
+
* The renderer routes к the system pane via `appendSystemLine`
|
|
1265
|
+
* so the snapshot lands as a single contiguous block в the
|
|
1266
|
+
* conversation transcript. Migrating к the Ink `<StatusTable>`
|
|
1267
|
+
* mounted directly в the REPL frame is a follow-up sprint —
|
|
1268
|
+
* keeping the line-buffered path here avoids cycling the
|
|
1269
|
+
* conversation pane's render model mid-α7.
|
|
1270
|
+
*/
|
|
1271
|
+
async dispatchStatus() {
|
|
1272
|
+
try {
|
|
1273
|
+
const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
|
|
1274
|
+
// Find the most-recent operator transcript row + its timestamp
|
|
1275
|
+
// so the snapshot's `Last cmd` field has real content в REPL
|
|
1276
|
+
// mode. Walking от newest end is O(transcript) worst case but
|
|
1277
|
+
// bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
|
|
1278
|
+
let lastCommand = null;
|
|
1279
|
+
let lastCommandAtEpochMs = null;
|
|
1280
|
+
for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
|
|
1281
|
+
const row = this.state.transcript[i];
|
|
1282
|
+
if (row.source === 'operator') {
|
|
1283
|
+
lastCommand = row.text;
|
|
1284
|
+
lastCommandAtEpochMs = row.timestampEpochMs;
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
|
|
1289
|
+
const lines = [];
|
|
1290
|
+
await runStatusCommand({
|
|
1291
|
+
cwd: process.cwd(),
|
|
1292
|
+
home: defaultStatusHome(),
|
|
1293
|
+
env: process.env,
|
|
1294
|
+
json: false,
|
|
1295
|
+
liveSessionId: this.state.sessionId ?? null,
|
|
1296
|
+
sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
|
|
1297
|
+
liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
|
|
1298
|
+
lastCommand,
|
|
1299
|
+
lastCommandAtEpochMs,
|
|
1300
|
+
writeOutput: (_payload, text) => {
|
|
1301
|
+
for (const line of text.split('\n')) {
|
|
1302
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1303
|
+
if (trimmed.length > 0)
|
|
1304
|
+
lines.push(trimmed);
|
|
1305
|
+
}
|
|
1306
|
+
},
|
|
1307
|
+
});
|
|
1308
|
+
if (lines.length === 0) {
|
|
1309
|
+
this.appendSystemLine('/status: no output.');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
for (const line of lines)
|
|
1313
|
+
this.appendSystemLine(line);
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1317
|
+
this.appendSystemLine(`/status failed: ${message}`);
|
|
1318
|
+
}
|
|
1182
1319
|
}
|
|
1183
1320
|
/**
|
|
1184
1321
|
* α6.5 `/context` slash handler. Surfaces the three-tier context
|
|
@@ -2242,6 +2379,62 @@ export class ReplSession {
|
|
|
2242
2379
|
// persona -> 'persona'
|
|
2243
2380
|
// system -> 'system'
|
|
2244
2381
|
this.persistRow(row);
|
|
2382
|
+
// Leak L8 (2026-05-27): evaluate the auto-compact gate after
|
|
2383
|
+
// every appendRow that produces a transcript turn. Wrapped in a
|
|
2384
|
+
// setImmediate so the gate never blocks the input-handling fast
|
|
2385
|
+
// path; if the threshold is tripped, the auto-trigger dispatches
|
|
2386
|
+
// `/compact` in the background while the operator keeps typing.
|
|
2387
|
+
if (row.source === 'operator' || row.source === 'persona') {
|
|
2388
|
+
this.maybeAutoCompact();
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Auto-compact gate. Cheap: builds an in-memory token estimate from
|
|
2393
|
+
* the current transcript and consults `evaluateAutoCompact`. When the
|
|
2394
|
+
* gate fires AND a compaction is not already in flight, we dispatch
|
|
2395
|
+
* `/compact` with `trigger='auto'`. The fire-and-forget shape means
|
|
2396
|
+
* the input box stays responsive while the background round-trip
|
|
2397
|
+
* runs.
|
|
2398
|
+
*
|
|
2399
|
+
* Hysteresis: `compactionInFlight` blocks re-entry. The gate is
|
|
2400
|
+
* cleared when the dispatch promise resolves regardless of outcome
|
|
2401
|
+
* so a transient transport failure does not permanently disable the
|
|
2402
|
+
* auto-trigger.
|
|
2403
|
+
*/
|
|
2404
|
+
compactionInFlight = false;
|
|
2405
|
+
maybeAutoCompact() {
|
|
2406
|
+
if (this.compactionInFlight)
|
|
2407
|
+
return;
|
|
2408
|
+
if (!this.store || !this.localSessionId)
|
|
2409
|
+
return;
|
|
2410
|
+
if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
|
|
2411
|
+
return;
|
|
2412
|
+
// Token estimate from the in-memory transcript. The estimate is a
|
|
2413
|
+
// lower bound on actual context pressure (server-side system
|
|
2414
|
+
// prompts add overhead) but the 4-char/token heuristic plus the
|
|
2415
|
+
// 0.75 default threshold gives generous headroom.
|
|
2416
|
+
const texts = this.state.transcript.map((r) => r.text);
|
|
2417
|
+
const tokenCount = estimateTokensInMany(texts);
|
|
2418
|
+
// Conservative default: assume the smallest commonly-used window
|
|
2419
|
+
// (32k tokens for deepseek-v3.1). Resolving the live model slug
|
|
2420
|
+
// through DispatchFSM + admin-api adds latency on a hot path; the
|
|
2421
|
+
// 0.75 threshold + smallest-window assumption errs toward
|
|
2422
|
+
// EARLY trigger which is the safe direction.
|
|
2423
|
+
const verdict = evaluateAutoCompact({
|
|
2424
|
+
tokenCount,
|
|
2425
|
+
windowSize: 32_000,
|
|
2426
|
+
});
|
|
2427
|
+
if (verdict.kind !== 'fire')
|
|
2428
|
+
return;
|
|
2429
|
+
this.compactionInFlight = true;
|
|
2430
|
+
void (async () => {
|
|
2431
|
+
try {
|
|
2432
|
+
await this.dispatchCompact('auto');
|
|
2433
|
+
}
|
|
2434
|
+
finally {
|
|
2435
|
+
this.compactionInFlight = false;
|
|
2436
|
+
}
|
|
2437
|
+
})();
|
|
2245
2438
|
}
|
|
2246
2439
|
/**
|
|
2247
2440
|
* Best-effort write of one transcript row into the local
|
|
@@ -2292,8 +2485,14 @@ export class ReplSession {
|
|
|
2292
2485
|
* write the restored events.
|
|
2293
2486
|
*/
|
|
2294
2487
|
restoreTranscript(events) {
|
|
2488
|
+
// Leak L8 (2026-05-27): apply compact-boundary masking BEFORE the
|
|
2489
|
+
// row conversion. Events strictly before the latest marker are
|
|
2490
|
+
// condensed into the boundary's `keptTailTurns + marker` slice so
|
|
2491
|
+
// the post-resume transcript starts at the most-recent context
|
|
2492
|
+
// floor rather than re-playing the full pre-compaction history.
|
|
2493
|
+
const masked = applyCompactMask(events);
|
|
2295
2494
|
const rows = [];
|
|
2296
|
-
for (const event of
|
|
2495
|
+
for (const event of masked) {
|
|
2297
2496
|
const row = eventToTranscriptRow(event);
|
|
2298
2497
|
if (row)
|
|
2299
2498
|
rows.push(row);
|
|
@@ -2481,6 +2680,25 @@ function eventToTranscriptRow(event) {
|
|
|
2481
2680
|
timestampEpochMs: event.t,
|
|
2482
2681
|
};
|
|
2483
2682
|
}
|
|
2683
|
+
if (event.kind === 'compaction') {
|
|
2684
|
+
// Leak L8: render the marker as a system separator line on
|
|
2685
|
+
// replay. The full summary text is intentionally NOT inlined here
|
|
2686
|
+
// (a 2k-token summary in the transcript would defeat the purpose
|
|
2687
|
+
// of compacting); the operator sees the "context compacted"
|
|
2688
|
+
// banner and can run `/context` to inspect the marker payload
|
|
2689
|
+
// when they want the details.
|
|
2690
|
+
const compactionPayload = (event.payload ?? null);
|
|
2691
|
+
const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
|
|
2692
|
+
const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
|
|
2693
|
+
? compactionPayload.summaryTurnsBefore
|
|
2694
|
+
: 0;
|
|
2695
|
+
return {
|
|
2696
|
+
id: randomUUID(),
|
|
2697
|
+
source: 'system',
|
|
2698
|
+
text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
|
|
2699
|
+
timestampEpochMs: event.t,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2484
2702
|
return null;
|
|
2485
2703
|
}
|
|
2486
2704
|
/**
|
|
@@ -38,7 +38,10 @@ import { listRoles } from '../agents/registry.js';
|
|
|
38
38
|
* silently appear here with empty placeholders.
|
|
39
39
|
*/
|
|
40
40
|
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
41
|
-
|
|
41
|
+
// Leak L8 (2026-05-27): /compact graduated from stub. The session
|
|
42
|
+
// module now owns the summariser round-trip + boundary marker append
|
|
43
|
+
// via `dispatchCompact`. Keep the type record exhaustive so a future
|
|
44
|
+
// stub addition cannot silently overlap the wired set.
|
|
42
45
|
memory: 'Session memory editor lands in α6.5b.',
|
|
43
46
|
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
44
47
|
// alpha 6.13: /privacy graduated from stub; nothing reads this at
|
|
@@ -62,7 +65,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
62
65
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
63
66
|
{ name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
|
|
64
67
|
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
65
|
-
{ name: 'compact', args: '', gloss: '
|
|
68
|
+
{ name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
|
|
66
69
|
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
67
70
|
{ name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
|
|
68
71
|
// Pugi tools
|
|
@@ -70,11 +73,12 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
70
73
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
71
74
|
{ name: 'cost', args: '', gloss: 'Session token + USD totals + last 5 turn breakdown', group: 'Pugi tools' },
|
|
72
75
|
{ name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
|
|
73
|
-
{ name: 'status', args: '', gloss: '
|
|
76
|
+
{ name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
|
|
74
77
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
75
78
|
// Settings
|
|
76
79
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
77
80
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
81
|
+
{ name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass)', group: 'Settings' },
|
|
78
82
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
79
83
|
{ name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
|
|
80
84
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
@@ -257,6 +261,49 @@ export function parseSlashCommand(input) {
|
|
|
257
261
|
// device flow + audit identity are wired correctly).
|
|
258
262
|
return { kind: 'privacy' };
|
|
259
263
|
}
|
|
264
|
+
case 'permissions':
|
|
265
|
+
case 'perms': {
|
|
266
|
+
// Leak L6: `/permissions [mode] [--persist] [--confirm]`.
|
|
267
|
+
//
|
|
268
|
+
// Argument grammar (single line, no quoting):
|
|
269
|
+
// /permissions -> show current mode + table
|
|
270
|
+
// /permissions plan|ask|allow -> flip mode
|
|
271
|
+
// /permissions bypass --confirm -> flip to bypass (refused
|
|
272
|
+
// without --confirm — safety)
|
|
273
|
+
// /permissions <mode> --persist -> also write to ~/.pugi/config.json
|
|
274
|
+
//
|
|
275
|
+
// Anything else returns an `error` result so the runtime can
|
|
276
|
+
// render the usage hint inline.
|
|
277
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
278
|
+
if (tokens.length === 0) {
|
|
279
|
+
return { kind: 'permissions', persist: false, confirmBypass: false };
|
|
280
|
+
}
|
|
281
|
+
const head0 = tokens[0]?.toLowerCase();
|
|
282
|
+
if (head0 !== 'plan' && head0 !== 'ask' && head0 !== 'allow' && head0 !== 'bypass') {
|
|
283
|
+
return {
|
|
284
|
+
kind: 'error',
|
|
285
|
+
message: `Usage: /permissions [plan|ask|allow|bypass] [--persist] [--confirm]; unknown mode '${tokens[0] ?? ''}'`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const flags = tokens.slice(1);
|
|
289
|
+
let persist = false;
|
|
290
|
+
let confirmBypass = false;
|
|
291
|
+
for (const flag of flags) {
|
|
292
|
+
if (flag === '--persist') {
|
|
293
|
+
persist = true;
|
|
294
|
+
}
|
|
295
|
+
else if (flag === '--confirm') {
|
|
296
|
+
confirmBypass = true;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
return {
|
|
300
|
+
kind: 'error',
|
|
301
|
+
message: `/permissions: unknown flag '${flag}' (allowed: --persist, --confirm)`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { kind: 'permissions', mode: head0, persist, confirmBypass };
|
|
306
|
+
}
|
|
260
307
|
case 'init': {
|
|
261
308
|
// β1 Sl11: surface the init flow inside the REPL. Tail args
|
|
262
309
|
// are ignored — the init handler is parameterless today; `pugi
|
|
@@ -280,7 +327,14 @@ export function parseSlashCommand(input) {
|
|
|
280
327
|
// shell surface, not the slash one).
|
|
281
328
|
return { kind: 'doctor' };
|
|
282
329
|
}
|
|
283
|
-
case 'compact':
|
|
330
|
+
case 'compact': {
|
|
331
|
+
// Leak L8 (2026-05-27): graduated from stub. The session module
|
|
332
|
+
// owns the summariser round-trip; tail args are ignored today
|
|
333
|
+
// because the surface is parameterless. Operators wanting a
|
|
334
|
+
// per-session compact run `pugi compact --session <id>` from a
|
|
335
|
+
// fresh shell.
|
|
336
|
+
return { kind: 'compact' };
|
|
337
|
+
}
|
|
284
338
|
case 'memory':
|
|
285
339
|
case 'config':
|
|
286
340
|
case 'budget':
|