mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 20:03:33 +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
|
|
@ -3223,9 +3223,9 @@ export const MODELS = {
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.24,
|
input: 0.23900000000000002,
|
||||||
output: 0.38,
|
output: 0.378,
|
||||||
cacheRead: 0.02,
|
cacheRead: 0.11,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 163840,
|
contextWindow: 163840,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- **Session hooks API redesign**: Merged `branch` event into `session` event. `BranchEvent`, `BranchEventResult` types and `pi.on("branch", ...)` removed. Use `pi.on("session", ...)` with `reason: "before_branch" | "branch"` instead. `AgentSession.branch()` returns `{ cancelled }` instead of `{ skipped }`. `AgentSession.reset()` and `switchSession()` now return `boolean` (false if cancelled by hook). RPC commands `reset`, `switch_session`, and `branch` now include `cancelled` in response data. ([#278](https://github.com/badlogic/pi-mono/issues/278))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Session lifecycle hooks**: Added `before_*` variants (`before_switch`, `before_clear`, `before_branch`) that fire before actions and can be cancelled with `{ cancel: true }`. Added `shutdown` reason for graceful exit handling. ([#278](https://github.com/badlogic/pi-mono/issues/278))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **File tab completion display**: File paths no longer get cut off early. Folders now show trailing `/` and removed redundant "directory"/"file" labels to maximize horizontal space. ([#280](https://github.com/badlogic/pi-mono/issues/280))
|
- **File tab completion display**: File paths no longer get cut off early. Folders now show trailing `/` and removed redundant "directory"/"file" labels to maximize horizontal space. ([#280](https://github.com/badlogic/pi-mono/issues/280))
|
||||||
|
|
|
||||||
|
|
@ -117,51 +117,55 @@ user sends another prompt ◄─────────────────
|
||||||
|
|
||||||
user branches (/branch)
|
user branches (/branch)
|
||||||
│
|
│
|
||||||
├─► branch (BEFORE branch, can control)
|
├─► session (reason: "before_branch", can cancel)
|
||||||
└─► session (reason: "switch", AFTER branch)
|
└─► session (reason: "branch", AFTER branch)
|
||||||
|
|
||||||
user switches session (/session)
|
user switches session (/session)
|
||||||
│
|
│
|
||||||
└─► session (reason: "switch")
|
├─► session (reason: "before_switch", can cancel)
|
||||||
|
└─► session (reason: "switch", AFTER switch)
|
||||||
|
|
||||||
user clears session (/clear)
|
user clears session (/clear)
|
||||||
│
|
│
|
||||||
└─► session (reason: "clear")
|
├─► session (reason: "before_clear", can cancel)
|
||||||
|
└─► session (reason: "clear", AFTER clear)
|
||||||
|
|
||||||
|
user exits (double Ctrl+C or Ctrl+D)
|
||||||
|
│
|
||||||
|
└─► session (reason: "shutdown")
|
||||||
```
|
```
|
||||||
|
|
||||||
A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
|
A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
|
||||||
|
|
||||||
### session
|
### session
|
||||||
|
|
||||||
Fired on startup and when session changes.
|
Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
pi.on("session", async (event, ctx) => {
|
pi.on("session", async (event, ctx) => {
|
||||||
// event.entries: SessionEntry[] - all session entries
|
// event.entries: SessionEntry[] - all session entries
|
||||||
// event.sessionFile: string | null - current session file (null with --no-session)
|
// event.sessionFile: string | null - current session file (null with --no-session)
|
||||||
// event.previousSessionFile: string | null - previous session file
|
// event.previousSessionFile: string | null - previous session file
|
||||||
// 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" and "branch"
|
||||||
|
|
||||||
|
// Cancel a before_* action:
|
||||||
|
if (event.reason === "before_clear") {
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
// No return needed if not cancelling
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reasons:**
|
**Reasons:**
|
||||||
- `start`: Initial session load on startup
|
- `start`: Initial session load on startup
|
||||||
- `switch`: User switched sessions (`/session`) or branched (`/branch`)
|
- `before_switch` / `switch`: User switched sessions (`/session`)
|
||||||
- `clear`: User cleared the session (`/clear`)
|
- `before_clear` / `clear`: User cleared the session (`/clear`)
|
||||||
|
- `before_branch` / `branch`: User branched the session (`/branch`)
|
||||||
|
- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)
|
||||||
|
|
||||||
### branch
|
For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.
|
||||||
|
|
||||||
Fired BEFORE a branch happens. Can control branch behavior.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
pi.on("branch", async (event, ctx) => {
|
|
||||||
// event.targetTurnIndex: number
|
|
||||||
// event.entries: SessionEntry[]
|
|
||||||
return { skipConversationRestore: true }; // or undefined
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: After branch completes, a `session` event fires with `reason: "switch"`.
|
|
||||||
|
|
||||||
### agent_start / agent_end
|
### agent_start / agent_end
|
||||||
|
|
||||||
|
|
@ -544,9 +548,12 @@ 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);
|
const ref = checkpoints.get(event.targetTurnIndex);
|
||||||
if (!ref) return undefined;
|
if (!ref) return;
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Restore code state?", [
|
const choice = await ctx.ui.select("Restore code state?", [
|
||||||
"Yes, restore code to that point",
|
"Yes, restore code to that point",
|
||||||
|
|
@ -557,8 +564,6 @@ export default function (pi: HookAPI) {
|
||||||
await ctx.exec("git", ["stash", "apply", ref]);
|
await ctx.exec("git", ["stash", "apply", ref]);
|
||||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("agent_end", async () => {
|
pi.on("agent_end", async () => {
|
||||||
|
|
@ -712,17 +717,26 @@ User sends prompt:
|
||||||
|
|
||||||
Branch:
|
Branch:
|
||||||
-> AgentSession.branch()
|
-> AgentSession.branch()
|
||||||
-> hookRunner.emit({ type: "branch", ... }) # BEFORE branch
|
-> hookRunner.emit({ type: "session", reason: "before_branch", ... }) # can cancel
|
||||||
-> [branch happens]
|
-> [if not cancelled: branch happens]
|
||||||
-> hookRunner.emit({ type: "session", reason: "switch", ... }) # AFTER
|
-> hookRunner.emit({ type: "session", reason: "branch", ... })
|
||||||
|
|
||||||
Session switch:
|
Session switch:
|
||||||
-> AgentSession.switchSession()
|
-> AgentSession.switchSession()
|
||||||
|
-> hookRunner.emit({ type: "session", reason: "before_switch", ... }) # can cancel
|
||||||
|
-> [if not cancelled: switch happens]
|
||||||
-> hookRunner.emit({ type: "session", reason: "switch", ... })
|
-> hookRunner.emit({ type: "session", reason: "switch", ... })
|
||||||
|
|
||||||
Clear:
|
Clear:
|
||||||
-> AgentSession.reset()
|
-> AgentSession.reset()
|
||||||
|
-> hookRunner.emit({ type: "session", reason: "before_clear", ... }) # can cancel
|
||||||
|
-> [if not cancelled: clear happens]
|
||||||
-> hookRunner.emit({ type: "session", reason: "clear", ... })
|
-> hookRunner.emit({ type: "session", reason: "clear", ... })
|
||||||
|
|
||||||
|
Shutdown (interactive mode):
|
||||||
|
-> handleCtrlC() or handleCtrlD()
|
||||||
|
-> hookRunner.emit({ type: "session", reason: "shutdown", ... })
|
||||||
|
-> process.exit(0)
|
||||||
```
|
```
|
||||||
|
|
||||||
## UI Context by Mode
|
## UI Context by Mode
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ Response:
|
||||||
|
|
||||||
#### reset
|
#### reset
|
||||||
|
|
||||||
Clear context and start a fresh session.
|
Clear context and start a fresh session. Can be cancelled by a `before_clear` hook.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "reset"}
|
{"type": "reset"}
|
||||||
|
|
@ -86,7 +86,12 @@ Clear context and start a fresh session.
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{"type": "response", "command": "reset", "success": true}
|
{"type": "response", "command": "reset", "success": true, "data": {"cancelled": false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a hook cancelled the reset:
|
||||||
|
```json
|
||||||
|
{"type": "response", "command": "reset", "success": true, "data": {"cancelled": true}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### State
|
### State
|
||||||
|
|
@ -465,7 +470,7 @@ Response:
|
||||||
|
|
||||||
#### switch_session
|
#### switch_session
|
||||||
|
|
||||||
Load a different session file.
|
Load a different session file. Can be cancelled by a `before_switch` hook.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
|
{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
|
||||||
|
|
@ -473,12 +478,17 @@ Load a different session file.
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{"type": "response", "command": "switch_session", "success": true}
|
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a hook cancelled the switch:
|
||||||
|
```json
|
||||||
|
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### branch
|
#### branch
|
||||||
|
|
||||||
Create a new branch from a previous user message. Returns the text of the message being branched from.
|
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "branch", "entryIndex": 2}
|
{"type": "branch", "entryIndex": 2}
|
||||||
|
|
@ -490,7 +500,17 @@ Response:
|
||||||
"type": "response",
|
"type": "response",
|
||||||
"command": "branch",
|
"command": "branch",
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {"text": "The original prompt text..."}
|
"data": {"text": "The original prompt text...", "cancelled": false}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a hook cancelled the branch:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"command": "branch",
|
||||||
|
"success": true,
|
||||||
|
"data": {"text": "The original prompt text...", "cancelled": true}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ Blocks writes to protected paths (.env, .git/, node_modules/).
|
||||||
### file-trigger.ts
|
### 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.
|
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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -38,8 +47,16 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
pi.on("session", async (event, ctx) => {
|
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
|
// 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) => {
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
|
@ -58,8 +75,7 @@ export default function (pi: HookAPI) {
|
||||||
```
|
```
|
||||||
|
|
||||||
**Available events:**
|
**Available events:**
|
||||||
- `session` - startup, session switch, clear
|
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
|
||||||
- `branch` - before branching (can skip conversation restore)
|
|
||||||
- `agent_start` / `agent_end` - per user prompt
|
- `agent_start` / `agent_end` - per user prompt
|
||||||
- `turn_start` / `turn_end` - per LLM turn
|
- `turn_start` / `turn_end` - per LLM turn
|
||||||
- `tool_call` - before tool execution (can block)
|
- `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);
|
const ref = checkpoints.get(event.targetTurnIndex);
|
||||||
if (!ref) return undefined;
|
if (!ref) return;
|
||||||
|
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) {
|
||||||
// In non-interactive mode, don't restore automatically
|
// In non-interactive mode, don't restore automatically
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Restore code state?", [
|
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]);
|
await ctx.exec("git", ["stash", "apply", ref]);
|
||||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("agent_end", async () => {
|
pi.on("agent_end", async () => {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { type BashResult, executeBash as executeBashCommand } from "./bash-execu
|
||||||
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
||||||
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
|
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
|
||||||
import { exportSessionToHtml } from "./export-html.js";
|
import { exportSessionToHtml } from "./export-html.js";
|
||||||
import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
|
import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
|
||||||
import type { BashExecutionMessage } from "./messages.js";
|
import type { BashExecutionMessage } from "./messages.js";
|
||||||
import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||||
import { loadSessionFromEntries, type SessionManager } from "./session-manager.js";
|
import { loadSessionFromEntries, type SessionManager } from "./session-manager.js";
|
||||||
|
|
@ -501,9 +501,26 @@ export class AgentSession {
|
||||||
* Reset agent and session to start fresh.
|
* Reset agent and session to start fresh.
|
||||||
* Clears all messages and starts a new session.
|
* Clears all messages and starts a new session.
|
||||||
* Listeners are preserved and will continue receiving events.
|
* Listeners are preserved and will continue receiving events.
|
||||||
|
* @returns true if reset completed, false if cancelled by hook
|
||||||
*/
|
*/
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<boolean> {
|
||||||
const previousSessionFile = this.sessionFile;
|
const previousSessionFile = this.sessionFile;
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
|
|
||||||
|
// Emit before_clear event (can be cancelled)
|
||||||
|
if (this._hookRunner?.hasHandlers("session")) {
|
||||||
|
const result = (await this._hookRunner.emit({
|
||||||
|
type: "session",
|
||||||
|
entries,
|
||||||
|
sessionFile: this.sessionFile,
|
||||||
|
previousSessionFile: null,
|
||||||
|
reason: "before_clear",
|
||||||
|
})) as SessionEventResult | undefined;
|
||||||
|
|
||||||
|
if (result?.cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._disconnectFromAgent();
|
this._disconnectFromAgent();
|
||||||
await this.abort();
|
await this.abort();
|
||||||
|
|
@ -526,6 +543,7 @@ export class AgentSession {
|
||||||
|
|
||||||
// Emit session event to custom tools
|
// Emit session event to custom tools
|
||||||
await this._emitToolSessionEvent("clear", previousSessionFile);
|
await this._emitToolSessionEvent("clear", previousSessionFile);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -1142,9 +1160,26 @@ export class AgentSession {
|
||||||
* Switch to a different session file.
|
* Switch to a different session file.
|
||||||
* Aborts current operation, loads messages, restores model/thinking.
|
* Aborts current operation, loads messages, restores model/thinking.
|
||||||
* Listeners are preserved and will continue receiving events.
|
* Listeners are preserved and will continue receiving events.
|
||||||
|
* @returns true if switch completed, false if cancelled by hook
|
||||||
*/
|
*/
|
||||||
async switchSession(sessionPath: string): Promise<void> {
|
async switchSession(sessionPath: string): Promise<boolean> {
|
||||||
const previousSessionFile = this.sessionFile;
|
const previousSessionFile = this.sessionFile;
|
||||||
|
const oldEntries = this.sessionManager.loadEntries();
|
||||||
|
|
||||||
|
// Emit before_switch event (can be cancelled)
|
||||||
|
if (this._hookRunner?.hasHandlers("session")) {
|
||||||
|
const result = (await this._hookRunner.emit({
|
||||||
|
type: "session",
|
||||||
|
entries: oldEntries,
|
||||||
|
sessionFile: this.sessionFile,
|
||||||
|
previousSessionFile: null,
|
||||||
|
reason: "before_switch",
|
||||||
|
})) as SessionEventResult | undefined;
|
||||||
|
|
||||||
|
if (result?.cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._disconnectFromAgent();
|
this._disconnectFromAgent();
|
||||||
await this.abort();
|
await this.abort();
|
||||||
|
|
@ -1191,18 +1226,19 @@ export class AgentSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._reconnectToAgent();
|
this._reconnectToAgent();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a branch from a specific entry index.
|
* Create a branch from a specific entry index.
|
||||||
* Emits branch event to hooks, which can control the branch behavior.
|
* Emits before_branch/branch session events to hooks.
|
||||||
*
|
*
|
||||||
* @param entryIndex Index into session entries to branch from
|
* @param entryIndex Index into session entries to branch from
|
||||||
* @returns Object with:
|
* @returns Object with:
|
||||||
* - selectedText: The text of the selected user message (for editor pre-fill)
|
* - selectedText: The text of the selected user message (for editor pre-fill)
|
||||||
* - skipped: True if a hook requested to skip conversation restore
|
* - cancelled: True if a hook cancelled the branch
|
||||||
*/
|
*/
|
||||||
async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> {
|
async branch(entryIndex: number): Promise<{ selectedText: string; cancelled: boolean }> {
|
||||||
const previousSessionFile = this.sessionFile;
|
const previousSessionFile = this.sessionFile;
|
||||||
const entries = this.sessionManager.loadEntries();
|
const entries = this.sessionManager.loadEntries();
|
||||||
const selectedEntry = entries[entryIndex];
|
const selectedEntry = entries[entryIndex];
|
||||||
|
|
@ -1213,19 +1249,20 @@ export class AgentSession {
|
||||||
|
|
||||||
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
|
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
|
||||||
|
|
||||||
// Emit branch event to hooks
|
// Emit before_branch event (can be cancelled)
|
||||||
let hookResult: BranchEventResult | undefined;
|
if (this._hookRunner?.hasHandlers("session")) {
|
||||||
if (this._hookRunner?.hasHandlers("branch")) {
|
const result = (await this._hookRunner.emit({
|
||||||
hookResult = (await this._hookRunner.emit({
|
type: "session",
|
||||||
type: "branch",
|
|
||||||
targetTurnIndex: entryIndex,
|
|
||||||
entries,
|
entries,
|
||||||
})) as BranchEventResult | undefined;
|
sessionFile: this.sessionFile,
|
||||||
}
|
previousSessionFile: null,
|
||||||
|
reason: "before_branch",
|
||||||
|
targetTurnIndex: entryIndex,
|
||||||
|
})) as SessionEventResult | undefined;
|
||||||
|
|
||||||
// If hook says skip conversation restore, don't branch
|
if (result?.cancel) {
|
||||||
if (hookResult?.skipConversationRestore) {
|
return { selectedText, cancelled: true };
|
||||||
return { selectedText, skipped: true };
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create branched session (returns null in --no-session mode)
|
// Create branched session (returns null in --no-session mode)
|
||||||
|
|
@ -1240,7 +1277,7 @@ export class AgentSession {
|
||||||
const newEntries = this.sessionManager.loadEntries();
|
const newEntries = this.sessionManager.loadEntries();
|
||||||
const loaded = loadSessionFromEntries(newEntries);
|
const loaded = loadSessionFromEntries(newEntries);
|
||||||
|
|
||||||
// Emit session event to hooks (in --no-session mode, both files are null)
|
// Emit branch event to hooks (after branch completes)
|
||||||
if (this._hookRunner) {
|
if (this._hookRunner) {
|
||||||
this._hookRunner.setSessionFile(newSessionFile);
|
this._hookRunner.setSessionFile(newSessionFile);
|
||||||
await this._hookRunner.emit({
|
await this._hookRunner.emit({
|
||||||
|
|
@ -1248,7 +1285,8 @@ export class AgentSession {
|
||||||
entries: newEntries,
|
entries: newEntries,
|
||||||
sessionFile: newSessionFile,
|
sessionFile: newSessionFile,
|
||||||
previousSessionFile,
|
previousSessionFile,
|
||||||
reason: "switch",
|
reason: "branch",
|
||||||
|
targetTurnIndex: entryIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1257,7 +1295,7 @@ export class AgentSession {
|
||||||
|
|
||||||
this.agent.replaceMessages(loaded.messages);
|
this.agent.replaceMessages(loaded.messages);
|
||||||
|
|
||||||
return { selectedText, skipped: false };
|
return { selectedText, cancelled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
BranchEvent,
|
|
||||||
BranchEventResult,
|
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
|
|
@ -21,6 +19,7 @@ export type {
|
||||||
LsToolResultEvent,
|
LsToolResultEvent,
|
||||||
ReadToolResultEvent,
|
ReadToolResultEvent,
|
||||||
SessionEvent,
|
SessionEvent,
|
||||||
|
SessionEventResult,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolCallEventResult,
|
ToolCallEventResult,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { LoadedHook, SendHandler } from "./loader.js";
|
import type { LoadedHook, SendHandler } from "./loader.js";
|
||||||
import type {
|
import type {
|
||||||
BranchEventResult,
|
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
HookError,
|
HookError,
|
||||||
HookEvent,
|
HookEvent,
|
||||||
HookEventContext,
|
HookEventContext,
|
||||||
HookUIContext,
|
HookUIContext,
|
||||||
|
SessionEventResult,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolCallEventResult,
|
ToolCallEventResult,
|
||||||
ToolResultEventResult,
|
ToolResultEventResult,
|
||||||
|
|
@ -217,11 +217,11 @@ export class HookRunner {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit an event to all hooks.
|
* Emit an event to all hooks.
|
||||||
* Returns the result from branch/tool_result events (if any handler returns one).
|
* Returns the result from session/tool_result events (if any handler returns one).
|
||||||
*/
|
*/
|
||||||
async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
|
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
|
||||||
const ctx = this.createContext();
|
const ctx = this.createContext();
|
||||||
let result: BranchEventResult | ToolResultEventResult | undefined;
|
let result: SessionEventResult | ToolResultEventResult | undefined;
|
||||||
|
|
||||||
for (const hook of this.hooks) {
|
for (const hook of this.hooks) {
|
||||||
const handlers = hook.handlers.get(event.type);
|
const handlers = hook.handlers.get(event.type);
|
||||||
|
|
@ -233,9 +233,13 @@ export class HookRunner {
|
||||||
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
||||||
timeout.clear();
|
timeout.clear();
|
||||||
|
|
||||||
// For branch events, capture the result
|
// For session events, capture the result (for before_* cancellation)
|
||||||
if (event.type === "branch" && handlerResult) {
|
if (event.type === "session" && handlerResult) {
|
||||||
result = handlerResult as BranchEventResult;
|
result = handlerResult as SessionEventResult;
|
||||||
|
// If cancelled, stop processing further hooks
|
||||||
|
if (result.cancel) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tool_result events, capture the result
|
// For tool_result events, capture the result
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,9 @@ export interface HookEventContext {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for session event.
|
* Base fields shared by all session events.
|
||||||
* Fired on startup and when session changes (switch or clear).
|
|
||||||
* Note: branch has its own event that fires BEFORE the branch happens.
|
|
||||||
*/
|
*/
|
||||||
export interface SessionEvent {
|
interface SessionEventBase {
|
||||||
type: "session";
|
type: "session";
|
||||||
/** All session entries (including pre-compaction history) */
|
/** All session entries (including pre-compaction history) */
|
||||||
entries: SessionEntry[];
|
entries: SessionEntry[];
|
||||||
|
|
@ -102,10 +100,32 @@ export interface SessionEvent {
|
||||||
sessionFile: string | null;
|
sessionFile: string | null;
|
||||||
/** Previous session file path, or null for "start" and "clear" */
|
/** Previous session file path, or null for "start" and "clear" */
|
||||||
previousSessionFile: string | null;
|
previousSessionFile: string | null;
|
||||||
/** Reason for the session event */
|
|
||||||
reason: "start" | "switch" | "clear";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for session events.
|
||||||
|
* Discriminated union based on reason.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - start: Initial session load
|
||||||
|
* - before_switch / switch: Session switch (e.g., /session command)
|
||||||
|
* - before_clear / clear: Session clear (e.g., /clear command)
|
||||||
|
* - before_branch / branch: Session branch (e.g., /branch command)
|
||||||
|
* - shutdown: Process exit (SIGINT/SIGTERM)
|
||||||
|
*
|
||||||
|
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
|
||||||
|
* Other events fire after the action completes.
|
||||||
|
*/
|
||||||
|
export type SessionEvent =
|
||||||
|
| (SessionEventBase & {
|
||||||
|
reason: "start" | "switch" | "clear" | "before_switch" | "before_clear" | "shutdown";
|
||||||
|
})
|
||||||
|
| (SessionEventBase & {
|
||||||
|
reason: "branch" | "before_branch";
|
||||||
|
/** Index of the turn to branch from */
|
||||||
|
targetTurnIndex: number;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for agent_start event.
|
* Event data for agent_start event.
|
||||||
* Fired when an agent loop starts (once per user prompt).
|
* Fired when an agent loop starts (once per user prompt).
|
||||||
|
|
@ -256,17 +276,6 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||||
return e.toolName === "ls";
|
return e.toolName === "ls";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Event data for branch event.
|
|
||||||
*/
|
|
||||||
export interface BranchEvent {
|
|
||||||
type: "branch";
|
|
||||||
/** Index of the turn to branch from */
|
|
||||||
targetTurnIndex: number;
|
|
||||||
/** Full session history */
|
|
||||||
entries: SessionEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of all hook event types.
|
* Union of all hook event types.
|
||||||
*/
|
*/
|
||||||
|
|
@ -277,8 +286,7 @@ export type HookEvent =
|
||||||
| TurnStartEvent
|
| TurnStartEvent
|
||||||
| TurnEndEvent
|
| TurnEndEvent
|
||||||
| ToolCallEvent
|
| ToolCallEvent
|
||||||
| ToolResultEvent
|
| ToolResultEvent;
|
||||||
| BranchEvent;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Event Results
|
// Event Results
|
||||||
|
|
@ -309,12 +317,12 @@ export interface ToolResultEventResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return type for branch event handlers.
|
* Return type for session event handlers.
|
||||||
* Allows hooks to control branch behavior.
|
* Allows hooks to cancel "before_*" actions.
|
||||||
*/
|
*/
|
||||||
export interface BranchEventResult {
|
export interface SessionEventResult {
|
||||||
/** If true, skip restoring the conversation (only restore code) */
|
/** If true, cancel the pending action (switch, clear, or branch) */
|
||||||
skipConversationRestore?: boolean;
|
cancel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -331,14 +339,14 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
|
||||||
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
|
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
|
||||||
*/
|
*/
|
||||||
export interface HookAPI {
|
export interface HookAPI {
|
||||||
on(event: "session", handler: HookHandler<SessionEvent>): void;
|
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
||||||
|
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
|
||||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||||
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
|
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
|
||||||
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
|
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
|
||||||
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to the agent.
|
* Send a message to the agent.
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
|
|
||||||
// Read before edit guideline
|
// Read before edit guideline
|
||||||
if (hasRead && hasEdit) {
|
if (hasRead && hasEdit) {
|
||||||
guidelinesList.push("Use read to examine files before editing");
|
guidelinesList.push("Use read to examine files before editing. You must use this tool instead of cat or sed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit guideline
|
// Edit guideline
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
BranchEvent,
|
|
||||||
BranchEventResult,
|
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
FindToolResultEvent,
|
FindToolResultEvent,
|
||||||
|
|
@ -54,6 +52,7 @@ export type {
|
||||||
LsToolResultEvent,
|
LsToolResultEvent,
|
||||||
ReadToolResultEvent,
|
ReadToolResultEvent,
|
||||||
SessionEvent,
|
SessionEvent,
|
||||||
|
SessionEventResult,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolCallEventResult,
|
ToolCallEventResult,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
|
|
|
||||||
|
|
@ -1162,8 +1162,7 @@ export class InteractiveMode {
|
||||||
private handleCtrlC(): void {
|
private handleCtrlC(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastSigintTime < 500) {
|
if (now - this.lastSigintTime < 500) {
|
||||||
this.stop();
|
void this.shutdown();
|
||||||
process.exit(0);
|
|
||||||
} else {
|
} else {
|
||||||
this.clearEditor();
|
this.clearEditor();
|
||||||
this.lastSigintTime = now;
|
this.lastSigintTime = now;
|
||||||
|
|
@ -1172,6 +1171,27 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private handleCtrlD(): void {
|
private handleCtrlD(): void {
|
||||||
// Only called when editor is empty (enforced by CustomEditor)
|
// Only called when editor is empty (enforced by CustomEditor)
|
||||||
|
void this.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully shutdown the agent.
|
||||||
|
* Emits shutdown event to hooks, then exits.
|
||||||
|
*/
|
||||||
|
private async shutdown(): Promise<void> {
|
||||||
|
// Emit shutdown event to hooks
|
||||||
|
const hookRunner = this.session.hookRunner;
|
||||||
|
if (hookRunner?.hasHandlers("session")) {
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
|
await hookRunner.emit({
|
||||||
|
type: "session",
|
||||||
|
entries,
|
||||||
|
sessionFile: this.session.sessionFile,
|
||||||
|
previousSessionFile: null,
|
||||||
|
reason: "shutdown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.stop();
|
this.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
@ -1496,8 +1516,8 @@ export class InteractiveMode {
|
||||||
userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
|
userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
|
||||||
async (entryIndex) => {
|
async (entryIndex) => {
|
||||||
const result = await this.session.branch(entryIndex);
|
const result = await this.session.branch(entryIndex);
|
||||||
if (result.skipped) {
|
if (result.cancelled) {
|
||||||
// Hook requested to skip conversation restore
|
// Hook cancelled the branch
|
||||||
done();
|
done();
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
return;
|
return;
|
||||||
|
|
@ -1533,8 +1553,7 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.stop();
|
void this.shutdown();
|
||||||
process.exit(0);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { component: selector, focus: selector.getSessionList() };
|
return { component: selector, focus: selector.getSessionList() };
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,11 @@ export class RpcClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset session (clear all messages).
|
* Reset session (clear all messages).
|
||||||
|
* @returns Object with `cancelled: true` if a hook cancelled the reset
|
||||||
*/
|
*/
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<{ cancelled: boolean }> {
|
||||||
await this.send({ type: "reset" });
|
const response = await this.send({ type: "reset" });
|
||||||
|
return this.getData(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -311,15 +313,18 @@ export class RpcClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch to a different session file.
|
* Switch to a different session file.
|
||||||
|
* @returns Object with `cancelled: true` if a hook cancelled the switch
|
||||||
*/
|
*/
|
||||||
async switchSession(sessionPath: string): Promise<void> {
|
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||||
await this.send({ type: "switch_session", sessionPath });
|
const response = await this.send({ type: "switch_session", sessionPath });
|
||||||
|
return this.getData(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branch from a specific message.
|
* Branch from a specific message.
|
||||||
|
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
|
||||||
*/
|
*/
|
||||||
async branch(entryIndex: number): Promise<{ text: string }> {
|
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
|
||||||
const response = await this.send({ type: "branch", entryIndex });
|
const response = await this.send({ type: "branch", entryIndex });
|
||||||
return this.getData(response);
|
return this.getData(response);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,8 +205,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "reset": {
|
case "reset": {
|
||||||
await session.reset();
|
const cancelled = !(await session.reset());
|
||||||
return success(id, "reset");
|
return success(id, "reset", { cancelled });
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
@ -339,13 +339,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "switch_session": {
|
case "switch_session": {
|
||||||
await session.switchSession(command.sessionPath);
|
const cancelled = !(await session.switchSession(command.sessionPath));
|
||||||
return success(id, "switch_session");
|
return success(id, "switch_session", { cancelled });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "branch": {
|
case "branch": {
|
||||||
const result = await session.branch(command.entryIndex);
|
const result = await session.branch(command.entryIndex);
|
||||||
return success(id, "branch", { text: result.selectedText, skipped: result.skipped });
|
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "get_branch_messages": {
|
case "get_branch_messages": {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export type RpcResponse =
|
||||||
| { id?: string; type: "response"; command: "prompt"; success: true }
|
| { id?: string; type: "response"; command: "prompt"; success: true }
|
||||||
| { id?: string; type: "response"; command: "queue_message"; success: true }
|
| { id?: string; type: "response"; command: "queue_message"; success: true }
|
||||||
| { id?: string; type: "response"; command: "abort"; success: true }
|
| { id?: string; type: "response"; command: "abort"; success: true }
|
||||||
| { id?: string; type: "response"; command: "reset"; success: true }
|
| { id?: string; type: "response"; command: "reset"; success: true; data: { cancelled: boolean } }
|
||||||
|
|
||||||
// State
|
// State
|
||||||
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
||||||
|
|
@ -142,8 +142,8 @@ export type RpcResponse =
|
||||||
// Session
|
// Session
|
||||||
| { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats }
|
| { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats }
|
||||||
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
|
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
|
||||||
| { id?: string; type: "response"; command: "switch_session"; success: true }
|
| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
|
||||||
| { id?: string; type: "response"; command: "branch"; success: true; data: { text: string } }
|
| { id?: string; type: "response"; command: "branch"; success: true; data: { text: string; cancelled: boolean } }
|
||||||
| {
|
| {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: "response";
|
type: "response";
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
|
||||||
// Branch from the first message
|
// Branch from the first message
|
||||||
const result = await session.branch(userMessages[0].entryIndex);
|
const result = await session.branch(userMessages[0].entryIndex);
|
||||||
expect(result.selectedText).toBe("Say hello");
|
expect(result.selectedText).toBe("Say hello");
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.cancelled).toBe(false);
|
||||||
|
|
||||||
// After branching, conversation should be empty (branched before the first message)
|
// After branching, conversation should be empty (branched before the first message)
|
||||||
expect(session.messages.length).toBe(0);
|
expect(session.messages.length).toBe(0);
|
||||||
|
|
@ -116,7 +116,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
|
||||||
// Branch from the first message
|
// Branch from the first message
|
||||||
const result = await session.branch(userMessages[0].entryIndex);
|
const result = await session.branch(userMessages[0].entryIndex);
|
||||||
expect(result.selectedText).toBe("Say hi");
|
expect(result.selectedText).toBe("Say hi");
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.cancelled).toBe(false);
|
||||||
|
|
||||||
// After branching, conversation should be empty
|
// After branching, conversation should be empty
|
||||||
expect(session.messages.length).toBe(0);
|
expect(session.messages.length).toBe(0);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue