@pugi/cli 0.1.0-beta.53 → 0.1.0-beta.54

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.
@@ -26,22 +26,40 @@ export const MAX_TOTAL_BYTES = 64 * 1024;
26
26
  /**
27
27
  * Source filenames we look for at the workspace root. Order matters:
28
28
  * PUGI.md is the canonical Pugi-native file; AGENTS.md is the
29
- * cross-CLI compatibility shim used by other agentic CLIs.
29
+ * cross-CLI compatibility shim used by other agentic CLIs; CLAUDE.md
30
+ * is the Claude Code drop-in compat shim (Wave 7 #20, 2026-05-29) —
31
+ * operators migrating from CC routinely keep a workspace-root
32
+ * `CLAUDE.md` documenting project conventions, and we pick it up so
33
+ * Pugi sees the same ambient guidance without a manual rename.
34
+ *
35
+ * All three files are loaded when present (no "first one wins"
36
+ * dropdown). Each entry is HTML-stripped + @import-expanded + capped
37
+ * against the shared 64 KB budget. Pugi's precedence convention is
38
+ * "shown last = highest specificity"; PUGI.md / AGENTS.md / CLAUDE.md
39
+ * each have a stable position in this list so the context builder's
40
+ * render order is deterministic.
30
41
  */
31
- export const MARKDOWN_SOURCES = ['PUGI.md', 'AGENTS.md'];
42
+ export const MARKDOWN_SOURCES = ['PUGI.md', 'AGENTS.md', 'CLAUDE.md'];
32
43
  /**
33
44
  * Load PUGI.md + AGENTS.md from `workspaceRoot`. Either or both may be
34
45
  * absent. Returns the combined load result with per-file detail plus a
35
46
  * flat list of warnings (best-effort: a missing file is a warning, not
36
47
  * an error).
37
48
  */
38
- export async function loadMarkdownContext(workspaceRoot) {
49
+ export async function loadMarkdownContext(workspaceRoot, env = process.env) {
39
50
  const warnings = [];
40
51
  const loaded = [];
41
52
  let budgetRemaining = MAX_TOTAL_BYTES;
42
53
  const visited = new Set();
43
54
  const absRoot = resolve(workspaceRoot);
44
- for (const source of MARKDOWN_SOURCES) {
55
+ // Wave 7 #20 (2026-05-29): allow operators to opt OUT of CLAUDE.md
56
+ // ingest via `PUGI_CC_COMPAT_DISABLE=1`. PUGI.md / AGENTS.md remain
57
+ // loaded — they are Pugi-native surfaces, not CC compat shims.
58
+ const ccCompatDisabled = env.PUGI_CC_COMPAT_DISABLE === '1';
59
+ const activeSources = ccCompatDisabled
60
+ ? MARKDOWN_SOURCES.filter((s) => s !== 'CLAUDE.md')
61
+ : MARKDOWN_SOURCES;
62
+ for (const source of activeSources) {
45
63
  const candidate = resolve(absRoot, source);
46
64
  if (!existsSync(candidate)) {
47
65
  warnings.push({
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OSC 8 hyperlink helpers — turns workspace-relative file paths into
3
+ * clickable links in modern terminals (iTerm2, kitty, Windows Terminal,
4
+ * VS Code integrated terminal, Alacritty, WezTerm). Dumb terminals and
5
+ * pipes ignore the escape sequence and render only the visible label.
6
+ *
7
+ * CEO P2 #38 — Wave 7 artifact linking. Triggers ONLY when:
8
+ * - stdout is a TTY
9
+ * - PUGI_ARTIFACT_LINKS_DISABLE !== '1'
10
+ * - NO_COLOR is not set
11
+ */
12
+ import { resolve, isAbsolute } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ const ESC = '';
15
+ export function linkArtifact(pathRel, opts) {
16
+ const env = opts.env ?? process.env;
17
+ const tty = opts.isTty ?? process.stdout.isTTY ?? false;
18
+ if (!tty)
19
+ return pathRel;
20
+ if (env['PUGI_ARTIFACT_LINKS_DISABLE'] === '1')
21
+ return pathRel;
22
+ if (env['NO_COLOR'] && env['NO_COLOR'].length > 0)
23
+ return pathRel;
24
+ const abs = isAbsolute(pathRel) ? pathRel : resolve(opts.workspaceRoot, pathRel);
25
+ const url = pathToFileURL(abs).href;
26
+ return `${ESC}]8;;${url}${ESC}\\${pathRel}${ESC}]8;;${ESC}\\`;
27
+ }
28
+ //# sourceMappingURL=osc8-link.js.map
@@ -128,12 +128,175 @@ const pugiSettingsSchema = z.object({
128
128
  })
129
129
  .optional(),
130
130
  });
131
- export function loadSettings(root) {
131
+ /**
132
+ * Wave 7 #20 (2026-05-29) — Claude Code drop-in compat ingest.
133
+ *
134
+ * Operators migrating from Claude Code typically keep a `.claude/`
135
+ * directory at workspace root with settings.json, slash commands,
136
+ * and ambient guidance files. We honour the existence of that
137
+ * directory and mirror the subset of keys that map cleanly onto
138
+ * Pugi's own settings surface — Pugi values ALWAYS win on conflict
139
+ * (the operator opted into Pugi as their primary), CC fills gaps.
140
+ *
141
+ * Opt-out: `PUGI_CC_COMPAT_DISABLE=1` short-circuits the merger and
142
+ * loads only `.pugi/settings.json` (or the empty default).
143
+ *
144
+ * Key mirror table:
145
+ * - `permissions.defaultMode` → `permissions.mode`
146
+ * (CC values map: `acceptEdits|plan|bypassPermissions|default` →
147
+ * `acceptEdits|plan|bypassPermissions|ask`).
148
+ * - `permissions.allow` → `permissions.allow` (concatenated, deduped).
149
+ * - `permissions.deny` → `permissions.deny` (concatenated, deduped).
150
+ * - `enabledPlugins` → ignored (CC-only concept; Pugi has its own
151
+ * plugin surface and we do not want to silently activate them).
152
+ * - `hooks` → currently ignored. Pugi's hook system is
153
+ * managed via `apps/pugi-cli/src/core/hooks/`; future work can
154
+ * wire CC hook entries through that bridge.
155
+ *
156
+ * Unknown CC keys are dropped on the floor by Zod's strip semantics
157
+ * just like the existing PUGI settings path — we never warn on
158
+ * unrecognised CC keys, because the CC surface is wider and we want
159
+ * fallthrough to be silent (operator does not need a stream of "we
160
+ * skipped this CC concept" warnings on every CLI invocation).
161
+ */
162
+ const ccPermissionsSchema = z
163
+ .object({
164
+ defaultMode: z.string().optional(),
165
+ allow: z.array(z.string()).optional(),
166
+ deny: z.array(z.string()).optional(),
167
+ })
168
+ .passthrough()
169
+ .optional();
170
+ const ccSettingsSchema = z
171
+ .object({
172
+ permissions: ccPermissionsSchema,
173
+ enabledPlugins: z.unknown().optional(),
174
+ hooks: z.unknown().optional(),
175
+ })
176
+ .passthrough();
177
+ /**
178
+ * Env var that disables CC compat ingest entirely. Useful for CI
179
+ * sandboxes where a stray `.claude/` from a parent checkout could
180
+ * otherwise leak permissions into Pugi.
181
+ */
182
+ export const CC_COMPAT_DISABLE_ENV = 'PUGI_CC_COMPAT_DISABLE';
183
+ /**
184
+ * Map a CC `permissions.defaultMode` to the closest Pugi permission
185
+ * mode. Unknown / missing values map to `undefined` so the caller
186
+ * keeps Pugi's own default.
187
+ *
188
+ * CC's `default` mode = "ask the user for each tool" which is Pugi's
189
+ * `ask` mode. `acceptEdits` / `plan` / `bypassPermissions` map 1:1.
190
+ */
191
+ export function mapCcPermissionMode(mode) {
192
+ if (typeof mode !== 'string')
193
+ return undefined;
194
+ switch (mode) {
195
+ case 'acceptEdits':
196
+ return 'acceptEdits';
197
+ case 'plan':
198
+ return 'plan';
199
+ case 'bypassPermissions':
200
+ return 'bypassPermissions';
201
+ case 'default':
202
+ return 'ask';
203
+ default:
204
+ return undefined;
205
+ }
206
+ }
207
+ /**
208
+ * Merge a parsed CC settings object into a Pugi settings object.
209
+ * Pugi ALWAYS wins on conflict; CC values fill gaps only.
210
+ */
211
+ export function mergeCcIntoPugi(pugi, cc, opts) {
212
+ const merged = {
213
+ ...pugi,
214
+ permissions: { ...pugi.permissions },
215
+ };
216
+ const pugiWroteMode = pugiPermissionKeyPresent(opts.pugiRawJson, 'mode');
217
+ if (!pugiWroteMode) {
218
+ const ccMode = mapCcPermissionMode(cc.permissions?.defaultMode);
219
+ if (ccMode)
220
+ merged.permissions.mode = ccMode;
221
+ }
222
+ if (Array.isArray(cc.permissions?.allow)) {
223
+ merged.permissions.allow = dedupeKeepFirst([
224
+ ...pugi.permissions.allow,
225
+ ...cc.permissions.allow,
226
+ ]);
227
+ }
228
+ if (Array.isArray(cc.permissions?.deny)) {
229
+ merged.permissions.deny = dedupeKeepFirst([
230
+ ...pugi.permissions.deny,
231
+ ...cc.permissions.deny,
232
+ ]);
233
+ }
234
+ // `enabledPlugins` and `hooks` are intentionally NOT mirrored. See
235
+ // the doc-block above for rationale.
236
+ void opts.pugiSettingsExisted;
237
+ return merged;
238
+ }
239
+ function pugiPermissionKeyPresent(raw, key) {
240
+ if (!raw || typeof raw !== 'object')
241
+ return false;
242
+ const permissions = raw.permissions;
243
+ if (!permissions || typeof permissions !== 'object')
244
+ return false;
245
+ return Object.prototype.hasOwnProperty.call(permissions, key);
246
+ }
247
+ function dedupeKeepFirst(items) {
248
+ const seen = new Set();
249
+ const out = [];
250
+ for (const item of items) {
251
+ if (seen.has(item))
252
+ continue;
253
+ seen.add(item);
254
+ out.push(item);
255
+ }
256
+ return out;
257
+ }
258
+ /**
259
+ * Read + parse `.claude/settings.json` at `root`. Returns `undefined`
260
+ * when the file is absent, malformed, or the operator has opted out
261
+ * via `PUGI_CC_COMPAT_DISABLE=1`. Never throws — a broken CC settings
262
+ * file degrades to "no CC compat ingest", not a Pugi boot crash.
263
+ */
264
+ export function loadCcSettings(root, env = process.env) {
265
+ if (env[CC_COMPAT_DISABLE_ENV] === '1')
266
+ return undefined;
267
+ const ccPath = resolve(root, '.claude/settings.json');
268
+ if (!existsSync(ccPath))
269
+ return undefined;
270
+ let parsed;
271
+ try {
272
+ parsed = JSON.parse(readFileSync(ccPath, 'utf8'));
273
+ }
274
+ catch {
275
+ return undefined;
276
+ }
277
+ const result = ccSettingsSchema.safeParse(parsed);
278
+ if (!result.success)
279
+ return undefined;
280
+ return result.data;
281
+ }
282
+ export function loadSettings(root, env = process.env) {
132
283
  const settingsPath = resolve(root, '.pugi/settings.json');
133
- if (!existsSync(settingsPath)) {
134
- return pugiSettingsSchema.parse({});
284
+ const pugiExists = existsSync(settingsPath);
285
+ let pugiRawJson = undefined;
286
+ let pugi;
287
+ if (pugiExists) {
288
+ pugiRawJson = JSON.parse(readFileSync(settingsPath, 'utf8'));
289
+ pugi = pugiSettingsSchema.parse(pugiRawJson);
290
+ }
291
+ else {
292
+ pugi = pugiSettingsSchema.parse({});
135
293
  }
136
- const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
137
- return pugiSettingsSchema.parse(parsed);
294
+ const cc = loadCcSettings(root, env);
295
+ if (!cc)
296
+ return pugi;
297
+ return mergeCcIntoPugi(pugi, cc, {
298
+ pugiSettingsExisted: pugiExists,
299
+ pugiRawJson,
300
+ });
138
301
  }
139
302
  //# sourceMappingURL=settings.js.map
@@ -5,6 +5,7 @@ import { realpathSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, relative, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { linkArtifact } from '../core/format/osc8-link.js';
8
9
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
9
10
  import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
11
  import { loadMcpRegistry } from '../core/mcp/registry.js';
@@ -4663,10 +4664,46 @@ function runEngineTask(kind) {
4663
4664
  });
4664
4665
  const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
4665
4666
  const taskId = `${kind}-${Date.now()}`;
4667
+ // CEO P1 #25 (2026-05-29) — Ctrl+C interrupt for non-REPL
4668
+ // dispatch (CC parity). Before this fix, SIGINT on `pugi code "..."`
4669
+ // killed the whole CLI process so the operator could not abort a
4670
+ // single bad turn without losing the session. We now:
4671
+ // 1. Bind an AbortController to the engine run so the first
4672
+ // Ctrl+C aborts THIS dispatch (the engine loop already
4673
+ // honours `ctx.signal` — see EngineContext in @pugi/sdk).
4674
+ // 2. Count presses: a second Ctrl+C within 2s falls through к
4675
+ // the legacy "hard exit" so the operator always has an
4676
+ // escape hatch (mirrors the REPL InputBox "press again к
4677
+ // exit" pattern).
4678
+ // 3. Restore Node's default SIGINT semantics in the `finally`
4679
+ // block so successive engine runs (e.g. within tests that
4680
+ // iterate runEngineTask) each see a fresh handler.
4681
+ const abortController = new AbortController();
4682
+ const SIGINT_DOUBLE_PRESS_WINDOW_MS = 2000;
4683
+ let firstSigintAt = null;
4684
+ const onSigint = () => {
4685
+ const now = Date.now();
4686
+ if (firstSigintAt !== null && now - firstSigintAt <= SIGINT_DOUBLE_PRESS_WINDOW_MS) {
4687
+ // Second press within the window — hard exit. Mirrors the
4688
+ // shell convention (^C ^C = give up) so the operator never
4689
+ // gets stuck on a runaway engine.
4690
+ process.stderr.write(`\npugi ${label}: interrupted (hard exit on ^C^C).\n`);
4691
+ process.exit(130);
4692
+ }
4693
+ firstSigintAt = now;
4694
+ if (!abortController.signal.aborted) {
4695
+ process.stderr.write(`\npugi ${label}: aborting current turn (press ^C again to exit).\n`);
4696
+ abortController.abort();
4697
+ }
4698
+ };
4699
+ process.on('SIGINT', onSigint);
4666
4700
  // β4 r2 P1 #3 — try/finally so loaded MCP child processes are
4667
4701
  // reaped regardless of run outcome (success, blocked, failed,
4668
- // thrown). The shutdown is best-effort; we never want a stuck
4669
- // MCP server to mask a successful Pugi run.
4702
+ // thrown). Triple-review P1 (#725): the try now wraps BOTH the
4703
+ // adapter.run() call и the iteration, so a sync throw from
4704
+ // adapter.run() still hits the SIGINT-detach + MCP-cleanup
4705
+ // finally block. Before this fix a sync throw would have leaked
4706
+ // the SIGINT listener (10+ runs → MaxListenersExceededWarning).
4670
4707
  try {
4671
4708
  const events = adapter.run({
4672
4709
  id: taskId,
@@ -4680,7 +4717,7 @@ function runEngineTask(kind) {
4680
4717
  // executor refusal sentinel). The permission mode here is the
4681
4718
  // workspace-level toggle and is unchanged from interactive default.
4682
4719
  permissionMode: 'auto',
4683
- }, { sessionId: session.id });
4720
+ }, { sessionId: session.id, signal: abortController.signal });
4684
4721
  const statusEvents = [];
4685
4722
  let result = null;
4686
4723
  for await (const event of events) {
@@ -4896,8 +4933,13 @@ function runEngineTask(kind) {
4896
4933
  textLines.push(`Summary: ${result.summary}`);
4897
4934
  if (result.filesChanged.length > 0) {
4898
4935
  textLines.push(`Files modified (${result.filesChanged.length}):`);
4899
- for (const file of result.filesChanged)
4900
- textLines.push(` - ${file}`);
4936
+ // CEO P2 #38 (Wave 7 artifact linking): emit each file as an OSC 8
4937
+ // hyperlink so modern terminals (iTerm2, kitty, VS Code, Windows
4938
+ // Terminal, Alacritty, WezTerm) let the operator click straight
4939
+ // into the file. Falls back to plain text on dumb terminals / CI.
4940
+ for (const file of result.filesChanged) {
4941
+ textLines.push(` - ${linkArtifact(file, { workspaceRoot: process.cwd() })}`);
4942
+ }
4901
4943
  }
4902
4944
  else if (kind !== 'explain' && kind !== 'plan') {
4903
4945
  textLines.push('Files modified: none');
@@ -4925,6 +4967,13 @@ function runEngineTask(kind) {
4925
4967
  writeOutput(flags, payload, textLines.join('\n'));
4926
4968
  }
4927
4969
  finally {
4970
+ // CEO P1 #25 — detach the per-run SIGINT handler so a second
4971
+ // engine run from the same process (tests, scripts iterating
4972
+ // `runEngineTask`, future REPL non-watch dispatches) starts
4973
+ // with a fresh press counter and does NOT pile up listeners
4974
+ // on the process object (Node prints
4975
+ // `MaxListenersExceededWarning` at 10).
4976
+ process.off('SIGINT', onSigint);
4928
4977
  // β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
4929
4978
  // CLI exits. shutdown() is idempotent and swallows per-server
4930
4979
  // disconnect errors, so it is safe even if no servers connected.
@@ -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.53');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.54');
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.
@@ -85,6 +85,7 @@ export async function bashTool(input, ctx) {
85
85
  nextCwd: ctx.lastBashCwd ?? ctx.root,
86
86
  truncated: false,
87
87
  timedOut: false,
88
+ cancelled: false,
88
89
  };
89
90
  }
90
91
  // Permission gate via the new class-aware engine.
@@ -119,6 +120,25 @@ export async function bashTool(input, ctx) {
119
120
  nextCwd: ctx.lastBashCwd ?? ctx.root,
120
121
  truncated: false,
121
122
  timedOut: false,
123
+ cancelled: false,
124
+ };
125
+ }
126
+ // CEO P1 #25 (2026-05-29) — pre-spawn cancellation check. Fires
127
+ // AFTER the permission gate so a cancelled brief never reaches
128
+ // /bin/sh even when the command would have been allowed. Mirrors
129
+ // the `gateOnCancellation` pattern from file-tools.ts.
130
+ if (ctx.cancellation?.isAborted === true) {
131
+ const reason = 'operator_aborted: bash refused before spawn';
132
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn' });
133
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
134
+ return {
135
+ stdout: '',
136
+ stderr: reason,
137
+ exitCode: 130,
138
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
139
+ truncated: false,
140
+ timedOut: false,
141
+ cancelled: true,
122
142
  };
123
143
  }
124
144
  // Background job branch.
@@ -147,6 +167,12 @@ export async function bashTool(input, ctx) {
147
167
  // before the timeout watchdog fires, we enforce a live ceiling
148
168
  // (BASH_LIVE_OUTPUT_CAP_BYTES) and SIGTERM the child when crossed.
149
169
  let truncatedMidStream = false;
170
+ // CEO P1 #25 (2026-05-29) — mid-stream operator cancellation. The
171
+ // listener registered against the CancellationToken below flips
172
+ // this flag and SIGTERMs the child. The close handler reads it to
173
+ // decide between `cancelled` (operator abort) and `timedOut`
174
+ // (watchdog).
175
+ let cancelledMidStream = false;
150
176
  const enforceLiveCap = () => {
151
177
  if (truncatedMidStream)
152
178
  return;
@@ -160,21 +186,88 @@ export async function bashTool(input, ctx) {
160
186
  // child already exited; the close handler will run
161
187
  }
162
188
  };
189
+ // CEO P1 #25 (2026-05-29) — live stream callback. When the REPL
190
+ // host wires `onStreamChunk`, we forward each stdout/stderr chunk
191
+ // in real time so the conversation pane / tool-stream pane paint
192
+ // bytes as they arrive instead of waiting for the child to exit.
193
+ // We invoke the callback inside a try/catch so a buggy sink
194
+ // (renderer crash, assertion error) never escalates to killing
195
+ // the bash dispatch. The buffered path below still captures the
196
+ // chunk so the model + audit trail stay consistent regardless of
197
+ // renderer health.
198
+ const onStreamChunk = ctx.onStreamChunk;
199
+ const emitStreamChunk = onStreamChunk
200
+ ? (stream, chunk) => {
201
+ try {
202
+ onStreamChunk({ stream, data: chunk.toString('utf8') });
203
+ }
204
+ catch {
205
+ // Sink crash — swallow.
206
+ }
207
+ }
208
+ : null;
163
209
  child.stdout?.on('data', (chunk) => {
164
- if (truncatedMidStream)
210
+ if (truncatedMidStream || cancelledMidStream)
165
211
  return;
166
212
  stdoutChunks.push(chunk);
167
213
  stdoutBytes += chunk.length;
214
+ if (emitStreamChunk)
215
+ emitStreamChunk('stdout', chunk);
168
216
  enforceLiveCap();
169
217
  });
170
218
  child.stderr?.on('data', (chunk) => {
171
- if (truncatedMidStream)
219
+ if (truncatedMidStream || cancelledMidStream)
172
220
  return;
173
221
  stderrChunks.push(chunk);
174
222
  stderrBytes += chunk.length;
223
+ if (emitStreamChunk)
224
+ emitStreamChunk('stderr', chunk);
175
225
  enforceLiveCap();
176
226
  });
227
+ // CEO P1 #25 — wire the cancellation token to SIGTERM. We track
228
+ // the detach handle so a successful run releases the listener
229
+ // instead of leaving it pinned to a long-lived REPL
230
+ // CancellationToken (same anti-leak pattern as
231
+ // native-pugi.ts:262).
232
+ let detachCancelListener;
233
+ if (ctx.cancellation && !ctx.cancellation.isAborted) {
234
+ const onAbort = () => {
235
+ if (cancelledMidStream)
236
+ return;
237
+ cancelledMidStream = true;
238
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'mid_stream' });
239
+ try {
240
+ child.kill('SIGTERM');
241
+ }
242
+ catch {
243
+ // child already exited; close handler will run
244
+ }
245
+ // SIGKILL escalation if the child does not honour SIGTERM
246
+ // within the grace window. Mirrors the timeout watchdog's
247
+ // two-phase shutdown.
248
+ setTimeout(() => {
249
+ if (child.exitCode !== null || child.signalCode !== null)
250
+ return;
251
+ try {
252
+ child.kill('SIGKILL');
253
+ }
254
+ catch {
255
+ // gone between the check and the signal
256
+ }
257
+ }, BASH_SIGKILL_GRACE_MS).unref();
258
+ };
259
+ detachCancelListener = ctx.cancellation.onAbort(onAbort);
260
+ }
177
261
  const timeoutOutcome = await waitWithTimeout(child, timeoutMs);
262
+ // Detach the cancellation listener on completion so a long-lived
263
+ // REPL token does not retain a reference to the dead child + this
264
+ // closure.
265
+ if (detachCancelListener) {
266
+ try {
267
+ detachCancelListener();
268
+ }
269
+ catch { /* listener already drained */ }
270
+ }
178
271
  const stdoutFull = Buffer.concat(stdoutChunks).toString('utf8');
179
272
  const stderrFull = Buffer.concat(stderrChunks).toString('utf8');
180
273
  const combinedBytes = stdoutBytes + stderrBytes;
@@ -199,6 +292,25 @@ export async function bashTool(input, ctx) {
199
292
  stdoutOut = capToCombined(stdoutFull, stderrFull).stdout;
200
293
  stderrOut = capToCombined(stdoutFull, stderrFull).stderr;
201
294
  }
295
+ // CEO P1 #25 — cancellation wins races against timeout / cap
296
+ // overflow. The token already aborted by the time the close
297
+ // handler fires; we distinguish operator-driven termination from
298
+ // the watchdog so the REPL transcript reads "Aborted." rather
299
+ // than "Timed out."
300
+ if (cancelledMidStream) {
301
+ const reason = 'operator_aborted: bash killed mid-stream';
302
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
303
+ return {
304
+ stdout: stdoutOut,
305
+ stderr: stderrOut === '' ? reason : `${stderrOut}\n${reason}`,
306
+ exitCode: 130,
307
+ artifactRef,
308
+ nextCwd,
309
+ truncated,
310
+ timedOut: false,
311
+ cancelled: true,
312
+ };
313
+ }
202
314
  if (truncatedMidStream) {
203
315
  // We killed the child because output cap exceeded mid-stream.
204
316
  // Report that as the failure cause rather than as a timeout —
@@ -217,6 +329,7 @@ export async function bashTool(input, ctx) {
217
329
  nextCwd,
218
330
  truncated: true,
219
331
  timedOut: false,
332
+ cancelled: false,
220
333
  };
221
334
  }
222
335
  if (timeoutOutcome.timedOut) {
@@ -230,6 +343,7 @@ export async function bashTool(input, ctx) {
230
343
  nextCwd,
231
344
  truncated,
232
345
  timedOut: true,
346
+ cancelled: false,
233
347
  };
234
348
  }
235
349
  const exitCode = timeoutOutcome.exitCode;
@@ -242,6 +356,7 @@ export async function bashTool(input, ctx) {
242
356
  nextCwd,
243
357
  truncated,
244
358
  timedOut: false,
359
+ cancelled: false,
245
360
  };
246
361
  }
247
362
  function sanitizeTimeout(value) {
@@ -471,6 +586,7 @@ function runBackground(input) {
471
586
  nextCwd: ctx.lastBashCwd ?? ctx.root,
472
587
  truncated: false,
473
588
  timedOut: false,
589
+ cancelled: false,
474
590
  };
475
591
  }
476
592
  /**
@@ -807,6 +923,7 @@ export function bashToolSync(input, ctx) {
807
923
  nextCwd: ctx.lastBashCwd ?? ctx.root,
808
924
  truncated: false,
809
925
  timedOut: false,
926
+ cancelled: false,
810
927
  };
811
928
  }
812
929
  const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
@@ -838,6 +955,27 @@ export function bashToolSync(input, ctx) {
838
955
  nextCwd: ctx.lastBashCwd ?? ctx.root,
839
956
  truncated: false,
840
957
  timedOut: false,
958
+ cancelled: false,
959
+ };
960
+ }
961
+ // CEO P1 #25 — sync path observes pre-spawn cancellation too. The
962
+ // sync path is used by the engine-loop tool-bridge (`bashToolSync`
963
+ // from tool-bridge.ts:1385); we cannot mid-stream cancel that path
964
+ // without rewriting spawnSync, but the pre-spawn gate still gives
965
+ // the operator a quick-exit window between permission and shell
966
+ // launch.
967
+ if (ctx.cancellation?.isAborted === true) {
968
+ const reason = 'operator_aborted: bash refused before spawn';
969
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn_sync' });
970
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
971
+ return {
972
+ stdout: '',
973
+ stderr: reason,
974
+ exitCode: 130,
975
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
976
+ truncated: false,
977
+ timedOut: false,
978
+ cancelled: true,
841
979
  };
842
980
  }
843
981
  const timeoutMs = sanitizeTimeout(input.timeoutMs);
@@ -885,6 +1023,7 @@ export function bashToolSync(input, ctx) {
885
1023
  nextCwd,
886
1024
  truncated,
887
1025
  timedOut,
1026
+ cancelled: false,
888
1027
  };
889
1028
  }
890
1029
  //# sourceMappingURL=bash.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.53",
3
+ "version": "0.1.0-beta.54",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.53"
58
+ "@pugi/sdk": "0.1.0-beta.54"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",