Skip to content
13 changes: 13 additions & 0 deletions packages/plugin/src/config/project-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ describe("stripUnsafeProjectConfigFields", () => {
expect(warnings.some((w) => w.includes("pi.subagent_extensions"))).toBe(true);
});

it("strips the subagent switches from project config", () => {
const raw: Record<string, unknown> = {
subagent: { compress: true, memory_write: true },
dreamer: { model: "x" },
};

const warnings = stripUnsafeProjectConfigFields(raw);

expect("subagent" in raw).toBe(false);
expect(raw.dreamer).toEqual({ model: "x" });
expect(warnings.some((w) => w.includes("subagent.*"))).toBe(true);
});

it("strips user-only embedding fields from project config but keeps safe tuning fields", () => {
const raw: Record<string, unknown> = {
embedding: {
Expand Down
13 changes: 13 additions & 0 deletions packages/plugin/src/config/project-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
* spending the user's provider account on folds.
* - `pi.subagent_extensions` — a cloned repo must not choose which extensions
* the user's Pi child processes load.
* - `subagent.*` — a cloned repo must not put its own child sessions on the
* historian-enabled compressed path or let them write the shared memory store.
* - `prompt_surface.guidance_override_path` / `tool_descriptions` — a repository
* may select a reviewed preset, but must not inject arbitrary guidance or tool
* description text into the user's provider-visible prompt.
Expand Down Expand Up @@ -490,6 +492,17 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
);
}

// subagent.* decides whether a child session takes the compressed (historian-
// enabled) path or may write the shared memory store. A repository may not opt
// its own cloned children into either; both stay user-tier choices, and a
// project-level value would also leak process-wide through the policy.
if ("subagent" in projectRaw) {
delete projectRaw.subagent;
warnings.push(
"Ignoring subagent.* from project config (security: only user-level config may let child sessions take the compressed path or write shared memory).",
);
}

for (const field of ["subc", "shadow_embedding"] as const) {
if (field in projectRaw) {
delete projectRaw[field];
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/config/schema/magic-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe("MagicContextConfigSchema", () => {
},
temporal_awareness: false,
keep_subagents: false,
subagent: { compress: false, memory_write: false },
debug_rpc: false,
todowrite: {
enabled: false,
Expand Down
29 changes: 29 additions & 0 deletions packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,16 @@ export interface MagicContextConfig {
* of deleting them on success. For short-term inspection/data collection;
* kept sessions accumulate until manually cleared. Default false. */
keep_subagents: boolean;
/** How Magic Context treats the caller's own subagent sessions. The stored
* `is_subagent` flag still records the parent link; these switches decide
* what that flag costs the session. Defaults keep the reduced path.
* USER-LEVEL ONLY: a project-scope config cannot set them. */
subagent: {
/** Let children use compartments, the historian and full guidance. */
compress: boolean;
/** Keep `ctx_memory` / `ctx_note` available to children. */
memory_write: boolean;
};
/** Enable loopback-only diagnostic RPCs that expose memory usage and heap snapshots. */
debug_rpc?: boolean;
/**
Expand Down Expand Up @@ -1494,6 +1504,25 @@ export const MagicContextConfigSchema = z
.describe(
"Debug: keep every settled Magic Context child session instead of deleting it after success: historian, all Dreamer tasks, smart-note evaluation and compilation, user-memory review, and memory migration. Kept Dreamer children can contain memory-pool text and user messages from other sessions of the same operator; their full transcript (prompt, tool calls, token usage, output) stays in the host session store. Kept sessions accumulate until manually cleared; leave false for normal use. Requires a restart to take effect. On OpenCode 2, one hidden session holds many runs; it is kept once any of its runs settled, and only a Dreamer session none of whose runs settled is still deleted.",
),
subagent: z
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
.object({
compress: z
.boolean()
.default(false)
.describe(
"Let child (subagent) sessions take the primary path: compartment injection, the historian, full guidance, mural, caveman and the Rust pass inputs. The stored is_subagent flag still records the parent link, and every gate that writes, delivers or reports (the memory tools unless subagent.memory_write, channel-2 nudges, /ctx-wrapup, the status surfaces) keeps reading it. Default false keeps every child on the reduced path.",
),
memory_write: z
.boolean()
.default(false)
.describe(
"Keep ctx_memory and ctx_note available to child sessions. Independent of subagent.compress: turning compression on does not hand a child the tools that write project memory unless this is also true.",
),
})
.default({ compress: false, memory_write: false })
.describe(
"How Magic Context treats the caller's own subagent sessions. USER-LEVEL ONLY: a project-scope config cannot set these. Two switches because they carry different risks: compress changes what a child sees and which context work it gets, memory_write lets a child write the shared memory store.",
),
debug_rpc: z
.boolean()
.default(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { resetSubagentPolicy, setSubagentPolicy } from "../../plugin/subagent-policy";
import { hideSubagentTools, resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy";
import { _resetHarnessForTesting, setHarness } from "../../shared/harness";
import { resetOpenCodeDbPathStateForTesting } from "../../shared/opencode-db-path";
import { Database } from "../../shared/sqlite";
Expand Down Expand Up @@ -37,6 +39,7 @@ beforeEach(() => {
});

afterEach(() => {
resetSubagentPolicy();
meta.close();
host.close();
_resetHarnessForTesting();
Expand Down Expand Up @@ -149,3 +152,22 @@ test("OC2 caps parent read retries, then retries after cooldown without making u
clock.mockRestore();
}
});

test("OC2 keeps a compressed child a child for the stored-fact gates on its first pass", () => {
setSubagentPolicy({ compress: true, memoryWrite: false });
// No session_meta row yet: this is the order the transform uses
// (hideSubagentTools runs before the pass stores the row).
const draft = {
sessionID: "child",
tools: { ctx_memory: {}, ctx_note: {}, ctx_search: {} },
};
hideSubagentTools(draft, meta);
expect(Object.keys(draft.tools)).toEqual(["ctx_search"]);
// The row now exists and still records the parent link...
expect(
meta.prepare("SELECT is_subagent FROM session_meta WHERE session_id = 'child'").get(),
).toEqual({ is_subagent: 1 });
// ...while the presentation projection hides it from the reduced-path gates.
expect(getOrCreateSessionMeta(meta, "child").isSubagent).toBe(false);
expect(resolveStoredSubagentMode(meta, "child")).toBe(true);
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Buffer } from "node:buffer";
import { clearLkgRecoveryFence } from "../../hooks/magic-context/lkg-recovery-fence";
import { effectiveSubagent } from "../../plugin/subagent-policy";
import {
cancelAutoSearchWork,
clearUnpublishedAutoSearchNoHints,
Expand Down Expand Up @@ -108,7 +109,14 @@ function findSessionMeta(db: Database, sessionId: string): SessionMeta | undefin
* lock uses this: inserting would need that same lock, fail, and refuse the turn.
*/
export function readSessionMeta(db: Database, sessionId: string): SessionMeta {
return findSessionMeta(db, sessionId) ?? getDefaultSessionMeta(sessionId);
return applySubagentPolicy(findSessionMeta(db, sessionId) ?? getDefaultSessionMeta(sessionId));
}

/** The stored fact plus the configured policy: `compress` hides it from the reduced-path gates. */
function applySubagentPolicy(meta: SessionMeta): SessionMeta {
const stored = meta.isSubagent;
const effective = effectiveSubagent(stored);
return effective === stored ? meta : { ...meta, isSubagent: effective };
}

const parentReadRetries = new WeakMap<
Expand All @@ -119,6 +127,10 @@ const PARENT_READ_ATTEMPTS = 5;
const PARENT_READ_COOLDOWN_MS = 30_000;

export function getOrCreateSessionMeta(db: Database, sessionId: string): SessionMeta {
return applySubagentPolicy(getOrCreateStoredSessionMeta(db, sessionId));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
}

function getOrCreateStoredSessionMeta(db: Database, sessionId: string): SessionMeta {
const existing = findSessionMeta(db, sessionId);
const harness = getHarness();
if (existing) {
Expand Down
7 changes: 4 additions & 3 deletions packages/plugin/src/hooks/magic-context/channel2-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@
// avoid it; that's fixed upstream now, so the separate client + probe are gone.

import { randomUUID } from "node:crypto";
import { getOrCreateSessionMeta } from "../../features/magic-context/storage";
import {
casChannel2NudgeClaim,
casChannel2NudgeState,
claimChannel2NudgeState,
getChannel2NudgeClaim,
getChannel2NudgeState,
} from "../../features/magic-context/storage-meta-persisted";
import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy";
import { sessionLog } from "../../shared/logger";
import { resolvePromptContext } from "../../shared/prompt-context";
import type { Database } from "../../shared/sqlite";
Expand Down Expand Up @@ -86,8 +86,9 @@ export interface Channel2DeliveryDeps {
*/
function subagentRunIsActive(deps: Channel2DeliveryDeps, sessionId: string): boolean {
try {
const meta = getOrCreateSessionMeta(deps.db, sessionId);
if (!meta.isSubagent) return true;
// The stored fact, not the `subagent.compress` projection: a child whose
// run is terminal must not have a follow-up turn started for it.
if (!resolveStoredSubagentMode(deps.db, sessionId)) return true;
return assistantAwaitingTools(deps, sessionId);
} catch (error) {
sessionLog(
Expand Down
22 changes: 11 additions & 11 deletions packages/plugin/src/hooks/magic-context/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import {
isCanonicalDreamTask,
} from "../../features/magic-context/dreamer/task-registry";
import type { ManualRunResult } from "../../features/magic-context/dreamer/task-scheduler";
import { getCompartments, getOrCreateSessionMeta } from "../../features/magic-context/storage";
import { getCompartments } from "../../features/magic-context/storage";
import type { RustSessionStatus } from "../../plugin/rpc-handlers";
import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy";
import { sessionLog } from "../../shared";
import type { MagicContextBuiltinCommandName } from "../../shared/builtin-commands";
import type { ConfigParseFailure } from "../../shared/config-diagnostics";
Expand Down Expand Up @@ -61,17 +62,16 @@ interface RecompConfirmation {
const recompConfirmationBySession = new Map<string, RecompConfirmation>();
const RECOMP_CONFIRMATION_WINDOW_MS = 60_000;

/**
* Gates anchored to the stored fact, never to `subagent.compress`: wrapup
* writes the session's own summary, so a compressed child stays ineligible.
*
* No catch: a metadata or database error has to surface here. Converting it to
* `false` would turn an unreadable child into a primary and let `/ctx-wrapup`
* write into a subagent session.
*/
function isSubagentSession(db: Database, sessionId: string): boolean {
const meta = getOrCreateSessionMeta(db, sessionId);
if (meta.isSubagent) return true;
try {
const row = db
.prepare("SELECT is_subagent FROM session_meta WHERE session_id = ?")
.get(sessionId) as { is_subagent?: unknown } | null;
return row?.is_subagent === 1 || row?.is_subagent === true;
} catch {
return false;
}
return resolveStoredSubagentMode(db, sessionId);
}

const RECOMP_USAGE = [
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin/src/hooks/magic-context/execute-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
formatCoordinateRebaseNotice,
readCoordinateRebaseNotice,
} from "../../features/magic-context/store-generation-rebase";
import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy";
import { formatCacheTtlDisplay, resolveCacheTtlDisplay } from "../../shared/cache-ttl-display";
import {
type ConfigParseFailure,
Expand Down Expand Up @@ -219,7 +220,7 @@ export function executeStatus(
"",
`**Protected tool tags:** ${protectionWindow.status.protectedCount} (${protectionWindow.status.protectedMass.toLocaleString()} tokens)`,
`**Protection floor:** ${protectionWindow.status.floor.toLocaleString()} tokens`,
`**Subagent session:** ${meta.isSubagent}`,
`**Subagent session:** ${resolveStoredSubagentMode(db, sessionId)}`,
];

const storage = getMagicContextStorageResolution();
Expand Down
8 changes: 5 additions & 3 deletions packages/plugin/src/hooks/magic-context/hook-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
setLastNudgeUndropped,
} from "../../features/magic-context/storage-meta-persisted";
import { clearSidebarSnapshotCache } from "../../plugin/sidebar-snapshot-cache";
import { resolveStoredSubagentMode } from "../../plugin/subagent-tool-policy";
import type { PluginContext } from "../../plugin/types";
import { seedSessionCacheTtlIfUnsynced } from "../../shared/cache-ttl-seed";
import { sessionLog } from "../../shared/logger";
Expand Down Expand Up @@ -767,9 +768,10 @@ export function createToolExecuteAfterHook(args: {
(t as { status?: unknown }).status === "cancelled"),
)
) {
// Subagents never deliver note nudges (gated in postprocess), so don't
// accumulate orphan trigger state for them.
if (sessionMeta && !sessionMeta.isSubagent) {
// Subagents never deliver note nudges: the delivery gate reads the
// stored fact, so do not accumulate trigger state that
// `subagent.compress` cannot hand to them.
if (sessionMeta && !resolveStoredSubagentMode(args.db, typedInput.sessionID)) {
onNoteTrigger(args.db, typedInput.sessionID, "todos_complete");
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { createMessagesTransformHandler } from "./plugin/messages-transform";
import { disableNativeAutoCompaction } from "./plugin/native-compaction-guard";
import { isDebugRpcEnabled, registerRpcHandlers } from "./plugin/rpc-handlers";
import { bindStaleBuildNotice } from "./plugin/stale-build-notice";
import { setSubagentPolicy } from "./plugin/subagent-policy";
import { primaryOnlyToolIds } from "./plugin/subagent-tool-policy";
import { createToolRegistry } from "./plugin/tool-registry";
import { getMagicContextBuiltinCommands } from "./shared/builtin-commands";
Expand Down Expand Up @@ -196,6 +197,11 @@ const server: Plugin = async (ctx) => {
// Debug data-collection toggle: when on, keep subagent child sessions
// (historian/dreamer/migration) instead of deleting on success.
setKeepSubagents(pluginConfig.keep_subagents === true);
// How much of the primary path a caller's own subagent sessions get.
setSubagentPolicy({
compress: pluginConfig.subagent.compress === true,
memoryWrite: pluginConfig.subagent.memory_write === true,
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
const autoUpdateAbort = new AbortController();
// Abort on process exit via the shared single-listener registry. Registering
// a process.once("exit") here directly would add one listener PER plugin
Expand Down
Loading
Loading