@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.
@@ -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
- dispatchStatus() {
1176
- const sessionId = this.state.sessionId ?? '(unbound)';
1177
- const reach = this.state.connection;
1178
- this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
1179
- this.appendSystemLine(`Session: ${sessionId}.`);
1180
- this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
1181
- this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
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 events) {
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
- compact: 'Manual context compaction lands in α6.5b.',
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: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
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: 'Backend + tenant status', group: 'Pugi tools' },
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':