Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,6 @@ package-lock.json
.chunkhound.json
.chunkhound/
.mcp.json

# macOS
.DS_Store
1,056 changes: 1,054 additions & 2 deletions agenticoding.test.ts

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions handoff/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat
if (ctx.hasUI) ctx.ui.notify("Usage: /handoff <direction>", "error");
return;
}
if (state.readonlyEnabled) {
if (ctx.hasUI) {
ctx.ui.notify(
"Readonly mode blocks /handoff. Use spawn only for same-topic delegation, or disable readonly with /readonly before a real handoff.",
"warning",
);
}
return;
}

state.pendingRequestedHandoff = {
direction,
Expand Down
159 changes: 156 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import { registerHandoffCompaction } from "./handoff/compact.js";
import { registerSpawnTool } from "./spawn/index.js";
import {
STATUS_KEY_HANDOFF,
STATUS_KEY_READONLY,
STATUS_KEY_TOPIC,
WIDGET_KEY_WARNING,
updateIndicators,
} from "./tui.js";
import { isSafeReadonlyCommand } from "./readonly-bash.js";
import { formatPagePreview } from "./notebook/store.js";

export default function (pi: ExtensionAPI): void {
Expand All @@ -56,6 +58,94 @@ export default function (pi: ExtensionAPI): void {
// ── Register commands ───────────────────────────────────────────
registerHandoffCommand(pi, state);

// ── Readonly mode ───────────────────────────────────────────────

pi.registerFlag("readonly", {
description: "Start in readonly mode",
type: "boolean",
default: false,
});

function toggleReadonly(ctx: ExtensionContext): void {
state.readonlyEnabled = !state.readonlyEnabled;
state.readonlyNudgePending = true;
pi.appendEntry("agenticoding-readonly", { enabled: state.readonlyEnabled });
updateIndicators(ctx, state);
ctx.ui.notify(
state.readonlyEnabled
? "Readonly mode enabled \u2014 write/edit/handoff/destructive-bash blocked"
: "Readonly mode disabled \u2014 write/edit/handoff/bash unblocked",
"info",
);
}

pi.registerCommand("readonly", {
description: "Toggle readonly mode (blocks write/edit/handoff/destructive-bash)",
handler: async (_args, ctx) => toggleReadonly(ctx),
});

pi.registerShortcut("ctrl+shift+r", {
description: "Toggle readonly mode",
handler: async (ctx) => {
if (ctx.isIdle()) toggleReadonly(ctx);
},
});

function rehydrateReadonlyState(ctx: ExtensionContext): void {
const wasEnabled = state.readonlyEnabled;
const branch = ctx.sessionManager?.getBranch?.() ?? [];
state.readonlyEnabled = false;
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i] as Record<string, unknown>;
if (
entry.type === "custom" &&
entry.customType === "agenticoding-readonly"
) {
state.readonlyEnabled = (entry.data as Record<string, unknown>)?.enabled === true;
break;
}
}
// CLI flag sets initial default, but branch state takes precedence after any toggle.
if (pi.getFlag("readonly") === true) {
const hasBranchEntry = branch.some(
(e) => (e as Record<string, unknown>).customType === "agenticoding-readonly"
);
if (!hasBranchEntry) {
state.readonlyEnabled = true;
}
}
// Nudge if readonly was activated by rehydration (CLI flag, branch restore, or undo)
if (state.readonlyEnabled && !wasEnabled) {
state.readonlyNudgePending = true;
}
}

// ── Readonly: tool_call blocking ────────────────────────────────
pi.on("tool_call", async (event) => {
if (!state.readonlyEnabled) return;

if (event.toolName === "write" || event.toolName === "edit" || event.toolName === "handoff") {
return {
block: true as const,
reason:
"Readonly mode: write/edit/handoff disabled. " +
"Use spawn for same-topic delegation, or disable readonly with /readonly before handoff.",
};
}

if (event.toolName === "bash") {
const cmd = (event.input as Record<string, unknown>).command as string;
if (!isSafeReadonlyCommand(cmd)) {
return {
block: true as const,
reason:
"Readonly mode: dangerous command blocked.\n" +
`Command: ${cmd}`,
};
}
}
});

// ── /notebook command — interactive page selector ────────────────
pi.registerCommand("notebook", {
description: "Select a notebook page to preview, or set the active notebook topic with /notebook <topic>",
Expand All @@ -65,7 +155,9 @@ export default function (pi: ExtensionAPI): void {
const result = setActiveNotebookTopic(state, topicArg, "human");
if (ctx.hasUI) {
const message = result.boundaryHint
? `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.`
? state.readonlyEnabled
? `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; use spawn only for same-topic delegation, or disable readonly with /readonly before handoff.`
: `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.`
: `Active notebook topic: ${result.current}`;
ctx.ui.notify(message, result.boundaryHint ? "warning" : "info");
}
Expand Down Expand Up @@ -201,13 +293,66 @@ export default function (pi: ExtensionAPI): void {
return { systemPrompt: parts.join("\n\n") };
});

// ── context: inject primacy-zone nudge before each LLM call ────
// ── context: inject primacy-zone nudge + readonly ON/OFF nudges ──────
// ON: nudge once on toggle. OFF: checks --readonly CLI flag and prior
// branch entries to detect session-level un-toggle before nudging.
pi.on("context", async (event, ctx: ExtensionContext) => {
const usage = ctx.getContextUsage();
const percent = usage?.percent ?? null;
if (usage && usage.percent !== null) {
state.lastContextPercent = usage.percent;
}

// Readonly ON/OFF nudge (one-shot, merged into the same context hook)
if (state.readonlyNudgePending) {
state.readonlyNudgePending = false;

if (state.readonlyEnabled) {
// ON nudge
return {
messages: [
...event.messages,
{
role: "custom" as const,
customType: "agenticoding-readonly-nudge",
content:
"Readonly mode is active. write, edit, handoff, and destructive " +
"bash operations are blocked. Allowed: read, notebook, safe bash, spawn for same-topic delegation. Disable readonly with /readonly before handoff.",
display: false,
timestamp: Date.now(),
},
],
};
} else {
const branch = ctx.sessionManager?.getBranch?.() ?? [];
const hasPriorOn = pi.getFlag("readonly") === true || branch.some(
(e) =>
(e as Record<string, unknown>).customType === "agenticoding-readonly" &&
((e as Record<string, unknown>).data as Record<string, unknown>)?.enabled === true,
);
if (hasPriorOn) {
return {
messages: [
...event.messages,
{
role: "custom" as const,
customType: "agenticoding-readonly-nudge",
content:
"Readonly mode has been turned off. You may now use write, edit, handoff, and bash freely." +
(percent !== null && percent >= 30
? " Context was at " + Math.round(percent) + "% — if the work changed topics, you can handoff now."
: ""),
display: false,
timestamp: Date.now(),
},
],
};
}
}
}

// Below primacy-zone threshold (~30%), skip watchdog unless a boundary
// hint is pending — context is still fresh enough that nudges add noise.
if (!state.pendingTopicBoundaryHint && (percent === null || percent < 30)) {
return;
}
Expand All @@ -228,17 +373,25 @@ export default function (pi: ExtensionAPI): void {
};
});

// ── session_start: reset state + update indicators ─────────────
// ── session_start: reset state + readonly rehydration + indicators ──
pi.on("session_start", async (event, ctx: ExtensionContext) => {
if (event.reason === "new") {
resetState(state);
// Clear any stale TUI indicators from the previous session
if (ctx.hasUI) {
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
ctx.ui.setStatus(STATUS_KEY_TOPIC, undefined);
ctx.ui.setStatus(STATUS_KEY_READONLY, undefined);
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);
}
}
rehydrateReadonlyState(ctx);
updateIndicators(ctx, state);
});

// ── session_tree: rehydrate readonly state on tree changes ─────
pi.on("session_tree", async (_event, ctx: ExtensionContext) => {
rehydrateReadonlyState(ctx);
updateIndicators(ctx, state);
});

Expand Down
Loading