pi-claude-supervisor 0.7.2 → 0.7.3
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/index.ts +4 -1
- package/src/policy.ts +61 -2
- package/src/worker/tmux-adapter.ts +50 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.7.3](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.2...v0.7.3) (2026-09-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **policy:** a name bound to literal text is not an unseen argument ([b93bbf6](https://github.com/btnalit/pi-claude-supervisor/commit/b93bbf6ae019fb7137588f113c0e1113ee942be7))
|
|
11
|
+
* **policy:** a tilde expands only at the start of a word ([9671b23](https://github.com/btnalit/pi-claude-supervisor/commit/9671b23cd6f112f28decc2e906c029c11f5f7d1c))
|
|
12
|
+
* **tmux:** a subagent hand-back is Claude's own prompt, not a human's ([90d40ba](https://github.com/btnalit/pi-claude-supervisor/commit/90d40baf357c5390f1699502235459e2d4230d55))
|
|
13
|
+
* **tmux:** remove a dead Worker's tmux socket and sweep leftovers at load ([db83d40](https://github.com/btnalit/pi-claude-supervisor/commit/db83d4016dc210739d386d038df220cd7c1c4479))
|
|
14
|
+
|
|
5
15
|
## [0.7.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.1...v0.7.2) (2026-09-17)
|
|
6
16
|
|
|
7
17
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { EventLog } from "./events.ts";
|
|
|
8
8
|
import { redactSensitive } from "./redaction.ts";
|
|
9
9
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
10
10
|
import { automaticWorkerEnvironment } from "./worker/environment.ts";
|
|
11
|
-
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
11
|
+
import { TmuxWorkerAdapter, attachCommand, sweepDeadTmuxSockets } from "./worker/tmux-adapter.ts";
|
|
12
12
|
import { Supervisor, type DecisionSessionClosedInfo, type HumanInterventionNotice, type SupervisorProgress, type SupervisorTokenUsage } from "./supervisor.ts";
|
|
13
13
|
import { evaluateCommand } from "./policy.ts";
|
|
14
14
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
@@ -128,6 +128,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
128
128
|
// with /supervise uninstall-hooks; the relay is a ~1 ms no-op when no
|
|
129
129
|
// Supervisor is listening, so leaving it installed costs nothing.
|
|
130
130
|
let hookInstallNotice: string | undefined;
|
|
131
|
+
// A Worker killed with its cgroup leaves its tmux socket behind; sweep the
|
|
132
|
+
// ones no server answers on so the temp directory does not fill with them.
|
|
133
|
+
if (transport === "tmux") sweepDeadTmuxSockets();
|
|
131
134
|
if (hookServer) {
|
|
132
135
|
hookServerReady = (async () => {
|
|
133
136
|
await writeRelayScript(relayPath);
|
package/src/policy.ts
CHANGED
|
@@ -333,7 +333,8 @@ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
|
|
336
|
-
const { tokens, embedded } = resolveDataTokens(rawTokens);
|
|
336
|
+
const { tokens: dataResolved, embedded } = resolveDataTokens(rawTokens);
|
|
337
|
+
const tokens = resolveLiteralBindings(dataResolved);
|
|
337
338
|
if (depth < 4) {
|
|
338
339
|
for (const body of embedded) {
|
|
339
340
|
const nestedResult = evaluateCommandInternal(body, depth + 1);
|
|
@@ -363,6 +364,61 @@ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): Policy
|
|
|
363
364
|
return { decision: "allow", reason: "command is allowed for unattended local development" };
|
|
364
365
|
}
|
|
365
366
|
|
|
367
|
+
/**
|
|
368
|
+
* `NAME=literal` and `for NAME in literal…` bind a name to text the policy can
|
|
369
|
+
* see, so a later `$NAME` is not an unseen argument: `for c in 5dae138 feff500;
|
|
370
|
+
* do git show $c; done` and `S=/tmp/x && cat > $S/log` are literal commands.
|
|
371
|
+
* Every bound value is substituted (a loop over `status push` yields
|
|
372
|
+
* `git status push …`, which the boundary checks still catch), and a name
|
|
373
|
+
* rebound to dynamic text is forgotten again.
|
|
374
|
+
*/
|
|
375
|
+
function resolveLiteralBindings(tokens: readonly ShellToken[]): ShellToken[] {
|
|
376
|
+
const bindings = new Map<string, string>();
|
|
377
|
+
const resolved: ShellToken[] = [];
|
|
378
|
+
const substitute = (token: ShellToken): ShellToken => {
|
|
379
|
+
if (token.operator || token.data || !token.dynamic || bindings.size === 0) return token;
|
|
380
|
+
const value = token.value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, (match, name: string) => bindings.get(name) ?? match);
|
|
381
|
+
if (value === token.value) return token;
|
|
382
|
+
const dynamic = /[$`*?[\]{}~]/u.test(value) && !/^[[\]{}]+$/u.test(value);
|
|
383
|
+
return { ...token, value, dynamic };
|
|
384
|
+
};
|
|
385
|
+
let commandPosition = true;
|
|
386
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
387
|
+
const token = substitute(tokens[index]!);
|
|
388
|
+
resolved.push(token);
|
|
389
|
+
if (token.operator) { commandPosition = SEGMENT_SPLIT_OPERATORS.has(token.value); continue; }
|
|
390
|
+
const word = token.value;
|
|
391
|
+
if (commandPosition) {
|
|
392
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su.exec(word);
|
|
393
|
+
if (assignment) {
|
|
394
|
+
if (token.dynamic) bindings.delete(assignment[1]!);
|
|
395
|
+
else bindings.set(assignment[1]!, assignment[2]!);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (word.toLowerCase() === "for") {
|
|
399
|
+
const name = tokens[index + 1]?.value;
|
|
400
|
+
const inWord = tokens[index + 2]?.value.toLowerCase();
|
|
401
|
+
if (name && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) && inWord === "in") {
|
|
402
|
+
const values: string[] = [];
|
|
403
|
+
let cursor = index + 3;
|
|
404
|
+
let literal = true;
|
|
405
|
+
for (; cursor < tokens.length; cursor += 1) {
|
|
406
|
+
const item = tokens[cursor]!;
|
|
407
|
+
if (item.operator || item.value.toLowerCase() === "do") break;
|
|
408
|
+
const substituted = substitute(item);
|
|
409
|
+
if (substituted.dynamic || substituted.data) literal = false;
|
|
410
|
+
values.push(substituted.value);
|
|
411
|
+
}
|
|
412
|
+
if (literal && values.length > 0) bindings.set(name, values.join(" "));
|
|
413
|
+
else bindings.delete(name);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!COMMAND_POSITION_KEYWORDS.has(word.toLowerCase())) commandPosition = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return resolved;
|
|
420
|
+
}
|
|
421
|
+
|
|
366
422
|
/**
|
|
367
423
|
* A quoted heredoc body means whatever its consumer makes of it. Fed to a shell
|
|
368
424
|
* (`bash <<'EOF'`, `cat <<'EOF' | sh`, `eval "$(cat <<'EOF' …)"`) it is a
|
|
@@ -534,7 +590,10 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
|
|
|
534
590
|
// Brace, tilde and pathname expansion can change command names, targets or
|
|
535
591
|
// Git refs after this lexical pass. Treat all unquoted expansion markers as
|
|
536
592
|
// dynamic rather than attempting to model Bash's expansion order.
|
|
537
|
-
|
|
593
|
+
// A tilde expands only at the start of a word (or of an assignment value);
|
|
594
|
+
// `HEAD~1` and `a~b` are literal text.
|
|
595
|
+
if (character === "~") { if (value === "" || /[=:]$/u.test(value)) dynamic = true; value += character; started = true; continue; }
|
|
596
|
+
if ("*?[]{}".includes(character)) { dynamic = true; value += character; started = true; continue; }
|
|
538
597
|
if (";&|<>".includes(character)) {
|
|
539
598
|
if (character === "<" && next === "<") {
|
|
540
599
|
const third = input[index + 2];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { constants as fsConstants, lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { access, chmod, lstat, mkdir, open, readdir, readFile, realpath, rm, rmdir, stat, truncate, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
@@ -798,6 +798,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
798
798
|
if (record.owned) {
|
|
799
799
|
await this.#stopGuardian(record).catch((error) => { record.cleanupError ??= asError(error); });
|
|
800
800
|
await this.#run(record, ["kill-server"], undefined, undefined, true).catch(() => {});
|
|
801
|
+
removeDeadTmuxSocket(record.handle.tmuxSocket);
|
|
801
802
|
} else await this.#detachPipe(record);
|
|
802
803
|
}
|
|
803
804
|
const deadline = Date.now() + 25_000;
|
|
@@ -1670,6 +1671,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1670
1671
|
if (isMissingSession(error)) record.serverKilled = true;
|
|
1671
1672
|
else record.cleanupError = asError(error);
|
|
1672
1673
|
}
|
|
1674
|
+
if (record.serverKilled) removeDeadTmuxSocket(record.handle.tmuxSocket);
|
|
1673
1675
|
if (!record.serverKilled) await this.#ensurePaneGone(record);
|
|
1674
1676
|
if (record.cgroupPath) {
|
|
1675
1677
|
try {
|
|
@@ -2281,9 +2283,49 @@ function normalizeForMatch(value: string): string {
|
|
|
2281
2283
|
return value.trim().replace(/\s+/gu, " ");
|
|
2282
2284
|
}
|
|
2283
2285
|
|
|
2284
|
-
/**
|
|
2286
|
+
/**
|
|
2287
|
+
* A tmux server removes its socket when it exits normally, but not when it is
|
|
2288
|
+
* killed with its cgroup; a dead `pi-cs-*.sock` is unlinked once no server
|
|
2289
|
+
* answers on it. `sweepDeadTmuxSockets` does the same for every socket the
|
|
2290
|
+
* adapter's naming scheme left behind in the temp directory.
|
|
2291
|
+
*/
|
|
2292
|
+
function removeDeadTmuxSocket(socketPath: string | undefined): void {
|
|
2293
|
+
if (!socketPath || !/[\\/]pi-cs-[0-9a-f-]+\.sock$/u.test(socketPath)) return;
|
|
2294
|
+
try {
|
|
2295
|
+
const probe = spawnSync("tmux", ["-S", socketPath, "list-sessions"], { stdio: "ignore", timeout: 2_000 });
|
|
2296
|
+
// A server that answers, or a socket something else holds open (the probe
|
|
2297
|
+
// timed out), is left alone.
|
|
2298
|
+
if (probe.status === 0 || probe.signal) return;
|
|
2299
|
+
unlinkSync(socketPath);
|
|
2300
|
+
} catch { /* already gone, or not ours to remove */ }
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
export function sweepDeadTmuxSockets(directory = tmpdir()): number {
|
|
2304
|
+
let removed = 0;
|
|
2305
|
+
let names: string[];
|
|
2306
|
+
try { names = readdirSync(directory); } catch { return 0; }
|
|
2307
|
+
for (const name of names) {
|
|
2308
|
+
if (!/^pi-cs-[0-9a-f-]+\.sock$/u.test(name)) continue;
|
|
2309
|
+
const socketPath = join(directory, name);
|
|
2310
|
+
try {
|
|
2311
|
+
if (!lstatSync(socketPath).isSocket()) continue;
|
|
2312
|
+
const probe = spawnSync("tmux", ["-S", socketPath, "list-sessions"], { stdio: "ignore", timeout: 2_000 });
|
|
2313
|
+
if (probe.status === 0 || probe.signal) continue;
|
|
2314
|
+
unlinkSync(socketPath);
|
|
2315
|
+
removed += 1;
|
|
2316
|
+
} catch { /* skip */ }
|
|
2317
|
+
}
|
|
2318
|
+
return removed;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* True for a prompt Claude Code injected itself, which a human never typed:
|
|
2323
|
+
* `<task-notification>`, `<system-reminder>`, `<agent-message …>` and the
|
|
2324
|
+
* other hyphenated runtime wrappers it frames delivered content with. A
|
|
2325
|
+
* person's own message does not begin with such a tag.
|
|
2326
|
+
*/
|
|
2285
2327
|
function isClaudeRuntimePrompt(prompt: string): boolean {
|
|
2286
|
-
return /^\s*<(
|
|
2328
|
+
return /^\s*<[a-z]+(?:-[a-z]+)+(?:\s|>)/u.test(prompt);
|
|
2287
2329
|
}
|
|
2288
2330
|
|
|
2289
2331
|
/**
|
|
@@ -2407,7 +2449,10 @@ function isPaneIdentityError(error: unknown): boolean {
|
|
|
2407
2449
|
}
|
|
2408
2450
|
|
|
2409
2451
|
function isMissingSession(error: unknown): boolean {
|
|
2410
|
-
|
|
2452
|
+
// tmux leaves its socket behind on exit ("no server running") unless it was
|
|
2453
|
+
// unlinked, in which case it reports "error connecting … (No such file or
|
|
2454
|
+
// directory)"; both mean the same thing here.
|
|
2455
|
+
return error instanceof Error && /(can't find session|no server running|session not found|failed to connect|error connecting to [^\n]*No such file or directory)/iu.test(error.message);
|
|
2411
2456
|
}
|
|
2412
2457
|
|
|
2413
2458
|
function isMissingFile(error: unknown): boolean {
|