@staix/agent-hub 0.30.0 → 0.31.0
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 +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DoctorCheck } from "./output.ts";
|
|
2
|
+
import { parseList, pluginState, type InstalledPlugin } from "./setup.ts";
|
|
2
3
|
|
|
3
4
|
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
|
|
4
5
|
const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
|
|
@@ -26,3 +27,52 @@ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unkno
|
|
|
26
27
|
} catch { unknown("plugin versus daemon", "plugin and daemon versions could not be compared"); }
|
|
27
28
|
return rows;
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #718 (#1179): the lines `ahub upgrade` ends with when the global CLI or the shared Claude plugin is older than the daemon it upgraded, or none. `ahub upgrade` upgrades one project's daemon
|
|
33
|
+
* only, so the person must update the CLI and the plugin themselves; without these lines a stale CLI later fails with a message that does not say why.
|
|
34
|
+
*
|
|
35
|
+
* It reuses `runtimeVersionWarnings`, the comparison `ahub doctor` prints, so the two never disagree about what is older. The one difference is a handover: when an older CLI hands the upgrade to the
|
|
36
|
+
* target release's own coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`, set by every release since #272), the process that prints is the target itself and cannot see the CLI that started it, but that
|
|
37
|
+
* CLI is older by construction (it handed over because the target was newer), so `handedOver` stands for it. An unknown plugin (no `claude`, unreadable inventory) is never a claim.
|
|
38
|
+
*/
|
|
39
|
+
export function globalUpdateAfterUpgrade(target: unknown, cli: unknown, plugin: unknown, handedOver = false): string[] {
|
|
40
|
+
const running = version(target);
|
|
41
|
+
if (!running) return [];
|
|
42
|
+
const warned = new Set(runtimeVersionWarnings(running, handedOver ? undefined : cli, plugin).filter(row => row.level === "warn").map(row => row.name));
|
|
43
|
+
const cliOlder = handedOver || warned.has("CLI versus daemon"), pluginOlder = warned.has("plugin versus daemon");
|
|
44
|
+
if (!cliOlder && !pluginOlder) return [];
|
|
45
|
+
return [
|
|
46
|
+
"ahub upgrade does not update the global CLI or the shared Claude plugin.",
|
|
47
|
+
cliOlder
|
|
48
|
+
? `${handedOver ? "The CLI that started this upgrade is older than the daemon this upgrade installs" : "The global CLI is older than the daemon this upgrade installs"} (${running}): run \`bun add -g @staix/agent-hub@${running}\` and then \`ahub setup\` yourself.`
|
|
49
|
+
: `The installed plugin is older than the daemon this upgrade installs (${running}): run \`ahub setup\` yourself.`,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GlobalUpdateInputs {
|
|
54
|
+
/** The daemon this upgrade installs. */
|
|
55
|
+
target: string;
|
|
56
|
+
/** This process's own version. */
|
|
57
|
+
cli: string;
|
|
58
|
+
/** True when an older CLI handed the upgrade to this release's coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`). */
|
|
59
|
+
handedOver: boolean;
|
|
60
|
+
/** Where this release's shipped plugin bundle lives, for `pluginState`. */
|
|
61
|
+
packageRoot: string;
|
|
62
|
+
/** The same `claude plugin list --json` probe `ahub doctor` runs. */
|
|
63
|
+
probe: () => { status: number | null; stdout?: string | null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* #718: `globalUpdateAfterUpgrade` with the plugin version read from the probe. Advice only: a missing `claude`, a probe that throws or times out, a non-zero exit or an unparsable list reads as an
|
|
68
|
+
* unknown plugin, which is never a claim, and nothing here can fail an upgrade.
|
|
69
|
+
*/
|
|
70
|
+
export function globalUpdateAdvice(input: GlobalUpdateInputs): string[] {
|
|
71
|
+
let plugin: string | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const probed = input.probe();
|
|
74
|
+
const state = pluginState(parseList<InstalledPlugin>(probed.status === 0 ? probed.stdout ?? "" : ""), input.packageRoot);
|
|
75
|
+
plugin = state.state === "missing" ? undefined : state.version;
|
|
76
|
+
} catch { /* an unknown plugin: the CLI half below does not depend on it */ }
|
|
77
|
+
try { return globalUpdateAfterUpgrade(input.target, input.cli, plugin, input.handedOver); } catch { return []; }
|
|
78
|
+
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
|
+
import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
|
|
2
3
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
4
|
|
|
4
|
-
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; reviewObserverHoldNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; attachMode?: "channel" | "tools-only"; attachEvidence?: string; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
6
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
7
|
+
export interface MemorySyncRow { state?: string; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; nextStep?: string }
|
|
8
|
+
export interface ReviewInputHealthRow { state?: string; cause?: string | null }
|
|
6
9
|
|
|
7
10
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
8
11
|
export function backendLabel(backend: BackendRow): string {
|
|
@@ -15,3 +18,40 @@ export function backendLabel(backend: BackendRow): string {
|
|
|
15
18
|
export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
|
|
16
19
|
return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
|
|
17
20
|
}
|
|
21
|
+
|
|
22
|
+
/** Human status copy for machine-local sync; upstream error text never enters this projection. */
|
|
23
|
+
export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new Date(at).toISOString()): string {
|
|
24
|
+
const allowedStates = ["disabled", "unavailable", "notrun", "not-configured", "stopped", "ok", "failed", "unknown"];
|
|
25
|
+
const state = allowedStates.includes(sync.state ?? "") ? sync.state! : "unknown";
|
|
26
|
+
const hasTimestamp = sync.at !== undefined;
|
|
27
|
+
const validTimestamp = Number.isSafeInteger(sync.at) && Math.abs(sync.at!) <= 8_640_000_000_000_000;
|
|
28
|
+
const stamp = hasTimestamp ? `; automatic, last ${validTimestamp ? time(sync.at!) : "unknown"}` : "";
|
|
29
|
+
if (state === "not-configured" || state === "stopped") return `${state}${stamp}; next: ${memorySyncNextStep(state === "stopped" ? "stopped" : "remote")}`;
|
|
30
|
+
if (state === "ok" && Number.isSafeInteger(sync.withheldCount) && sync.withheldCount! >= 0
|
|
31
|
+
&& Number.isSafeInteger(sync.exportedCount) && sync.exportedCount! >= 0)
|
|
32
|
+
return `ok${stamp}; ${sync.withheldCount} row${sync.withheldCount === 1 ? "" : "s"} withheld locally; ${sync.exportedCount} exported`;
|
|
33
|
+
if (state !== "failed") return `${state}${stamp}`;
|
|
34
|
+
const causes = ["declined", "remote", "binding", "stopped", "transport", "unknown"];
|
|
35
|
+
const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
|
|
36
|
+
if (cause === "declined" && sync.placementActive === false) {
|
|
37
|
+
const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
|
|
38
|
+
return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.";
|
|
39
|
+
}
|
|
40
|
+
const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
|
|
41
|
+
const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
|
|
42
|
+
return `failed (${cause}${count})${stamp}${ids}; next: ${memorySyncNextStep(cause)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Public-safe status guidance; never print journal exception text or provenance contents. */
|
|
46
|
+
export function reviewInputHealthLine(health: ReviewInputHealthRow): string | undefined {
|
|
47
|
+
if (health.state === "healthy") return undefined;
|
|
48
|
+
const cause = health.state === "corrupt" && ["provenance_corrupt", "provenance_incomplete"].includes(health.cause ?? "") ? health.cause!
|
|
49
|
+
: health.state === "corrupt" ? "provenance_corrupt"
|
|
50
|
+
: health.state === "incomplete" && ["provenance_incomplete", "incomplete_markers", "legacy_coverage"].includes(health.cause ?? "") ? health.cause!
|
|
51
|
+
: "unknown";
|
|
52
|
+
const next = cause === "incomplete_markers" ? "verify the peer's next ordinary input or confirmed session change"
|
|
53
|
+
: cause === "legacy_coverage" ? "keep affected work held until review-input coverage is confirmed"
|
|
54
|
+
: "keep affected work held; inspect or repair only review-input provenance, preserving delivery and task state";
|
|
55
|
+
const state = health.state === "incomplete" || health.state === "corrupt" ? health.state : "unknown";
|
|
56
|
+
return `${state} (${cause}); next: ${next}`;
|
|
57
|
+
}
|
package/src/cli/tail-render.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { BusEvent } from "../hub/bus.ts";
|
|
|
2
2
|
|
|
3
3
|
export function renderTailEvent(e: BusEvent): string {
|
|
4
4
|
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
5
|
+
if (e.t === "remote_delivery_settled") return ` . remote delivery ${e.deliveryId} settled for ${e.peer} (${e.outcome})`;
|
|
5
6
|
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
6
7
|
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
7
8
|
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
@@ -51,6 +51,11 @@ export interface UpgradeHost {
|
|
|
51
51
|
entry: string;
|
|
52
52
|
/** This CLI's version. */
|
|
53
53
|
version: string;
|
|
54
|
+
/**
|
|
55
|
+
* #718 (#1179): lines to print after the upgrade completed, under the summary: the global CLI or the shared Claude plugin is older than the upgraded daemon and `ahub upgrade` does not update either.
|
|
56
|
+
* Never part of a blocked, cancelled or abandoned operation, and empty when nothing is older or the versions are unknown.
|
|
57
|
+
*/
|
|
58
|
+
afterCompleted?(op: RecoveryOperation): Promise<string[]> | string[];
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function emit(io: ScreenIO, lines: readonly string[]): void { for (const line of lines) io.out(line); }
|
|
@@ -69,6 +74,13 @@ async function summary(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation):
|
|
|
69
74
|
const live = await host.endInspection?.(op).catch(() => ({} as Record<string, Inspection | undefined>));
|
|
70
75
|
emit(io, renderSummary(op, live ?? {}, io.presentation));
|
|
71
76
|
}
|
|
77
|
+
/** #718: the update step for the global CLI and plugin, once, after a completed upgrade (both callers have just seen `completed`). A host that cannot read the versions, or a failure reading them, prints nothing: it is advice, never a failure. */
|
|
78
|
+
async function globalUpdate(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation): Promise<void> {
|
|
79
|
+
if (op.plan.kind !== "upgrade") return;
|
|
80
|
+
let lines: string[] = [];
|
|
81
|
+
try { lines = await host.afterCompleted?.(op) ?? []; } catch { /* advice only */ }
|
|
82
|
+
for (const line of lines) human(io, line);
|
|
83
|
+
}
|
|
72
84
|
const clock = (io: ScreenIO) => io.presentation?.now() ?? Date.now();
|
|
73
85
|
|
|
74
86
|
const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await ask(io, question)) ?? "");
|
|
@@ -236,7 +248,7 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
|
|
|
236
248
|
for (let acted = false, reset = false; ;) {
|
|
237
249
|
if (io.interrupted()) return "quit";
|
|
238
250
|
const op = host.read(id), runner = host.runner(id);
|
|
239
|
-
if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
|
|
251
|
+
if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); await globalUpdate(host, io, op); return "completed"; }
|
|
240
252
|
if (op.phase === "cancelled") {
|
|
241
253
|
if (io.presentation) await summary(host, io, op);
|
|
242
254
|
else human(io, `operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
|
|
@@ -351,7 +363,7 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
|
|
|
351
363
|
try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { human(io, `ahub: ${(error as Error).message}`); continue; }
|
|
352
364
|
human(io, `operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
|
|
353
365
|
const end = await follow(host, io, op.id);
|
|
354
|
-
if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); return; }
|
|
366
|
+
if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); await globalUpdate(host, io, host.read(op.id)); return; }
|
|
355
367
|
if (end === "left") return;
|
|
356
368
|
// Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
|
|
357
369
|
}
|
package/src/hub/action-class.ts
CHANGED
|
@@ -171,6 +171,14 @@ function commands(text: string, nest = 0): Command[] | undefined {
|
|
|
171
171
|
function object(value: unknown): Record<string, unknown> | undefined {
|
|
172
172
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* #725 (#1193): the device files a redirect can name without writing any file: `2>/dev/null` is the common case. Matched as the exact literal the command wrote, never as a resolved path, so
|
|
176
|
+
* `/dev/../tmp/x`, `/dev//null`, `/dev/null/x` and `/dev/sda` stay outputs outside the root. Only `/dev/null` and `/dev/tty` are listed (the person's ruling): no descriptor stands behind either. `/dev/stdout`,
|
|
177
|
+
* `/dev/stderr`, `/dev/fd/1` and `/dev/fd/2` reopen whatever file fd 1 or 2 already points at, and a `>` truncates it (`sh -c 'echo x >/dev/stdout' >>/tmp/log` truncates /tmp/log on Linux), so they stay
|
|
178
|
+
* outputs, as does every `/dev/fd/<n>`. The redirect itself is still recorded (`redirected`), so the read-only git fast path stays closed.
|
|
179
|
+
*/
|
|
180
|
+
const DEVICE_OUTPUTS: ReadonlySet<string> = new Set(["/dev/null", "/dev/tty"]);
|
|
181
|
+
const isDeviceOutput = (target: string): boolean => DEVICE_OUTPUTS.has(target);
|
|
174
182
|
function outside(target: string, root: string, cwd: string): boolean {
|
|
175
183
|
if (!isAbsolute(root) || !target || /[$`]/.test(target)) return false;
|
|
176
184
|
if (target.startsWith("~") || !cwd && !isAbsolute(target)) return true;
|
|
@@ -646,7 +654,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
646
654
|
if (nested) sequence(nested, root, cwd, found, depth + 1, bare ? floor : undefined);
|
|
647
655
|
}
|
|
648
656
|
}
|
|
649
|
-
if (command.outputs.some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
657
|
+
if (command.outputs.some(p => !isDeviceOutput(p) && cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
650
658
|
if (program === "git") {
|
|
651
659
|
const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
|
|
652
660
|
["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
|