mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
Add before/after session events with cancellation support
- Merge branch event into session with before_branch/branch reasons
- Add before_switch, before_clear, shutdown reasons
- before_* events can be cancelled with { cancel: true }
- Update RPC commands to return cancelled status
- Add shutdown event on process exit
- New example hooks: confirm-destructive, dirty-repo-guard, auto-commit-on-exit
fixes #278
This commit is contained in:
parent
99081fce30
commit
42d7d9d9b6
20 changed files with 426 additions and 124 deletions
|
|
@ -16,6 +16,15 @@ Blocks writes to protected paths (.env, .git/, node_modules/).
|
|||
### file-trigger.ts
|
||||
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
|
||||
|
||||
### confirm-destructive.ts
|
||||
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.
|
||||
|
||||
### dirty-repo-guard.ts
|
||||
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.
|
||||
|
||||
### auto-commit-on-exit.ts
|
||||
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
|
|
@ -38,8 +47,16 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// event.reason: "start" | "switch" | "clear"
|
||||
// event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
|
||||
// "before_branch" | "branch" | "shutdown"
|
||||
// event.targetTurnIndex: number (only for before_branch/branch)
|
||||
// ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
|
||||
|
||||
// Cancel before_* actions:
|
||||
if (event.reason === "before_clear") {
|
||||
return { cancel: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
|
|
@ -58,8 +75,7 @@ export default function (pi: HookAPI) {
|
|||
```
|
||||
|
||||
**Available events:**
|
||||
- `session` - startup, session switch, clear
|
||||
- `branch` - before branching (can skip conversation restore)
|
||||
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
|
||||
- `agent_start` / `agent_end` - per user prompt
|
||||
- `turn_start` / `turn_end` - per LLM turn
|
||||
- `tool_call` - before tool execution (can block)
|
||||
|
|
|
|||
50
packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
Normal file
50
packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Auto-Commit on Exit Hook
|
||||
*
|
||||
* Automatically commits changes when the agent exits.
|
||||
* Uses the last assistant message to generate a commit message.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "shutdown") return;
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0 || status.trim().length === 0) {
|
||||
// Not a git repo or no changes
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last assistant message for commit context
|
||||
let lastAssistantText = "";
|
||||
for (let i = event.entries.length - 1; i >= 0; i--) {
|
||||
const entry = event.entries[i];
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = entry.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
lastAssistantText = content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a simple commit message
|
||||
const firstLine = lastAssistantText.split("\n")[0] || "Work in progress";
|
||||
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
|
||||
|
||||
// Stage and commit
|
||||
await ctx.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]);
|
||||
|
||||
if (commitCode === 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
62
packages/coding-agent/examples/hooks/confirm-destructive.ts
Normal file
62
packages/coding-agent/examples/hooks/confirm-destructive.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Confirm Destructive Actions Hook
|
||||
*
|
||||
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
||||
* Demonstrates how to cancel session events using the before_* variants.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_* events (the ones that can be cancelled)
|
||||
if (event.reason === "before_clear") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Clear session?",
|
||||
"This will delete all messages in the current session.",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_switch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const hasUnsavedWork = event.entries.some(
|
||||
(e) => e.type === "message" && e.message.role === "user",
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_branch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(
|
||||
`Branch from turn ${event.targetTurnIndex}?`,
|
||||
["Yes, create branch", "No, stay in current session"],
|
||||
);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
59
packages/coding-agent/examples/hooks/dirty-repo-guard.ts
Normal file
59
packages/coding-agent/examples/hooks/dirty-repo-guard.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Dirty Repo Guard Hook
|
||||
*
|
||||
* Prevents session changes when there are uncommitted git changes.
|
||||
* Useful to ensure work is committed before switching context.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only guard destructive actions
|
||||
if (
|
||||
event.reason !== "before_clear" &&
|
||||
event.reason !== "before_switch" &&
|
||||
event.reason !== "before_branch"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const action =
|
||||
event.reason === "before_clear"
|
||||
? "clear session"
|
||||
: event.reason === "before_switch"
|
||||
? "switch session"
|
||||
: "branch";
|
||||
|
||||
const choice = await ctx.ui.select(
|
||||
`You have ${changedFiles} uncommitted file(s). ${action} anyway?`,
|
||||
["Yes, proceed anyway", "No, let me commit first"],
|
||||
);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -19,13 +19,16 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
});
|
||||
|
||||
pi.on("branch", async (event, ctx) => {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_branch events
|
||||
if (event.reason !== "before_branch") return;
|
||||
|
||||
const ref = checkpoints.get(event.targetTurnIndex);
|
||||
if (!ref) return undefined;
|
||||
if (!ref) return;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, don't restore automatically
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select("Restore code state?", [
|
||||
|
|
@ -37,8 +40,6 @@ export default function (pi: HookAPI) {
|
|||
await ctx.exec("git", ["stash", "apply", ref]);
|
||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("agent_end", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue