@cleocode/cleo-os 2026.4.31 → 2026.4.36
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/dist/cli.d.ts +15 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +41 -14
- package/dist/cli.js.map +1 -1
- package/extensions/cleo-cant-bridge.d.ts +10 -0
- package/extensions/cleo-cant-bridge.d.ts.map +1 -1
- package/extensions/cleo-cant-bridge.js +89 -2
- package/extensions/cleo-cant-bridge.js.map +1 -1
- package/extensions/cleo-cant-bridge.ts +109 -2
- package/extensions/cleo-hooks-bridge.d.ts +44 -0
- package/extensions/cleo-hooks-bridge.d.ts.map +1 -0
- package/extensions/cleo-hooks-bridge.js +197 -0
- package/extensions/cleo-hooks-bridge.js.map +1 -0
- package/extensions/cleo-hooks-bridge.ts +272 -0
- package/extensions/cleo-startup.d.ts +228 -0
- package/extensions/cleo-startup.d.ts.map +1 -0
- package/extensions/cleo-startup.js +728 -0
- package/extensions/cleo-startup.js.map +1 -0
- package/extensions/cleo-startup.ts +1019 -0
- package/package.json +3 -3
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleoOS CAAMP hooks bridge — Pi event → CLEO hook translation.
|
|
3
|
+
*
|
|
4
|
+
* CANONICAL LOCATION: `packages/cleo-os/extensions/cleo-hooks-bridge.ts`
|
|
5
|
+
*
|
|
6
|
+
* Installed to: $XDG_DATA_HOME/cleo/extensions/cleo-hooks-bridge.js
|
|
7
|
+
* Loaded by: Pi via `--extension <path>` injected by CleoOS cli.ts
|
|
8
|
+
*
|
|
9
|
+
* Bridges Pi runtime events to CLEO's CAAMP hook vocabulary so that
|
|
10
|
+
* CLEO's existing hook handlers (session-hooks, task-hooks, conduit-hooks)
|
|
11
|
+
* are informed of Pi activity without requiring any changes to the hook
|
|
12
|
+
* substrate.
|
|
13
|
+
*
|
|
14
|
+
* Event → CAAMP mapping:
|
|
15
|
+
* Pi `tool_call` → `cleo memory observe` (PreToolUse)
|
|
16
|
+
* Pi `tool_result` → `cleo memory observe` (PostToolUse)
|
|
17
|
+
* Pi `before_agent_start` → `cleo memory observe` (SubagentStart)
|
|
18
|
+
*
|
|
19
|
+
* All observations are stored with `--type discovery` and `--sourceType auto`
|
|
20
|
+
* so they are distinguished from manual observations.
|
|
21
|
+
*
|
|
22
|
+
* Design constraints:
|
|
23
|
+
* - Best-effort: never crash Pi — all CLI calls wrapped in try/catch
|
|
24
|
+
* - NO top-level await; all work inside event handlers
|
|
25
|
+
* - Rate-limited: at most 1 observation per event type per 500 ms to
|
|
26
|
+
* avoid flooding brain.db with high-frequency tool calls
|
|
27
|
+
* - Tool names are sanitised before storage (no shell injection risk
|
|
28
|
+
* because we use execFileAsync, not shell)
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from "node:child_process";
|
|
33
|
+
import { promisify } from "node:util";
|
|
34
|
+
import { accentPrimary, textSecondary, ICON_FORGE, } from "./tui-theme.js";
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Rate-limiting state
|
|
38
|
+
// ============================================================================
|
|
39
|
+
/**
|
|
40
|
+
* Minimum milliseconds between observations of the same hook type.
|
|
41
|
+
* Prevents flooding brain.db on high-frequency tool invocations.
|
|
42
|
+
*/
|
|
43
|
+
const RATE_LIMIT_MS = 500;
|
|
44
|
+
/** Tracks the last emission timestamp per hook type key. */
|
|
45
|
+
const lastEmit = {};
|
|
46
|
+
/**
|
|
47
|
+
* Check whether a hook observation should be emitted, applying rate limiting.
|
|
48
|
+
*
|
|
49
|
+
* @param key - A string key identifying the hook type (e.g. "PreToolUse:bash").
|
|
50
|
+
* @returns `true` if the observation should be emitted now.
|
|
51
|
+
*/
|
|
52
|
+
function shouldEmit(key) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const last = lastEmit[key] ?? 0;
|
|
55
|
+
if (now - last < RATE_LIMIT_MS)
|
|
56
|
+
return false;
|
|
57
|
+
lastEmit[key] = now;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// CAAMP observation helpers
|
|
62
|
+
// ============================================================================
|
|
63
|
+
/**
|
|
64
|
+
* Sanitise a string for safe inclusion in a CLEO observation title/text.
|
|
65
|
+
*
|
|
66
|
+
* Trims and limits length to avoid oversized brain.db entries.
|
|
67
|
+
*
|
|
68
|
+
* @param raw - The raw string to sanitise.
|
|
69
|
+
* @param maxLen - Maximum character length (default 120).
|
|
70
|
+
* @returns The sanitised string.
|
|
71
|
+
*/
|
|
72
|
+
function sanitise(raw, maxLen = 120) {
|
|
73
|
+
return raw.trim().slice(0, maxLen);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Extract a human-readable summary from a `tool_call` event.
|
|
77
|
+
*
|
|
78
|
+
* @param event - The Pi tool_call event.
|
|
79
|
+
* @returns A brief description of the tool invocation.
|
|
80
|
+
*/
|
|
81
|
+
function summariseToolCall(event) {
|
|
82
|
+
const toolName = event.toolName ?? "unknown";
|
|
83
|
+
if (toolName === "bash") {
|
|
84
|
+
const cmd = event.input?.command ?? "";
|
|
85
|
+
return `bash: ${sanitise(cmd, 80)}`;
|
|
86
|
+
}
|
|
87
|
+
if (toolName === "edit" || toolName === "write" || toolName === "read") {
|
|
88
|
+
const path = event.input?.file_path ?? "";
|
|
89
|
+
return `${toolName}: ${sanitise(path, 80)}`;
|
|
90
|
+
}
|
|
91
|
+
return toolName;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fire a CAAMP observation via `cleo memory observe`.
|
|
95
|
+
*
|
|
96
|
+
* Best-effort: any error is silently swallowed to never crash Pi.
|
|
97
|
+
*
|
|
98
|
+
* @param text - Observation text to store.
|
|
99
|
+
* @param title - Short title for the observation.
|
|
100
|
+
* @param cwd - Project root directory.
|
|
101
|
+
*/
|
|
102
|
+
function fireObservation(text, title, cwd) {
|
|
103
|
+
execFileAsync("cleo", [
|
|
104
|
+
"memory", "observe",
|
|
105
|
+
"--title", sanitise(title, 120),
|
|
106
|
+
"--type", "discovery",
|
|
107
|
+
"--sourceType", "auto",
|
|
108
|
+
"--agent", "pi-hooks-bridge",
|
|
109
|
+
sanitise(text, 500),
|
|
110
|
+
], { timeout: 5_000, cwd }).catch(() => {
|
|
111
|
+
// Intentionally swallowed — best-effort only
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Status bar tracking
|
|
116
|
+
// ============================================================================
|
|
117
|
+
/** Count of hooks fired this session for the status bar display. */
|
|
118
|
+
let hooksFired = 0;
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// Pi extension factory
|
|
121
|
+
// ============================================================================
|
|
122
|
+
/**
|
|
123
|
+
* Pi extension factory for the CAAMP hooks bridge.
|
|
124
|
+
*
|
|
125
|
+
* Registers:
|
|
126
|
+
* - `tool_call` → PreToolUse observation
|
|
127
|
+
* - `tool_result` → PostToolUse observation
|
|
128
|
+
* - `before_agent_start` → SubagentStart observation
|
|
129
|
+
*
|
|
130
|
+
* @param pi - The Pi extension API instance.
|
|
131
|
+
*/
|
|
132
|
+
export default function (pi) {
|
|
133
|
+
// ── session_start: reset counters ──────────────────────────────────────
|
|
134
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
135
|
+
hooksFired = 0;
|
|
136
|
+
// Clear rate-limit state for fresh session
|
|
137
|
+
for (const key of Object.keys(lastEmit)) {
|
|
138
|
+
delete lastEmit[key];
|
|
139
|
+
}
|
|
140
|
+
if (ctx.hasUI) {
|
|
141
|
+
ctx.ui.setStatus("cleo-hooks-bridge", `${accentPrimary(ICON_FORGE)} hooks: 0`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// ── tool_call → PreToolUse ─────────────────────────────────────────────
|
|
145
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
146
|
+
const toolName = event.toolName ?? "unknown";
|
|
147
|
+
const rateKey = `PreToolUse:${toolName}`;
|
|
148
|
+
if (!shouldEmit(rateKey))
|
|
149
|
+
return {};
|
|
150
|
+
const summary = summariseToolCall(event);
|
|
151
|
+
fireObservation(`CAAMP PreToolUse — Pi invoked tool: ${summary}`, `PreToolUse: ${toolName}`, ctx.cwd);
|
|
152
|
+
hooksFired++;
|
|
153
|
+
if (ctx.hasUI) {
|
|
154
|
+
ctx.ui.setStatus("cleo-hooks-bridge", `${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`);
|
|
155
|
+
}
|
|
156
|
+
// Do not modify the tool call
|
|
157
|
+
return {};
|
|
158
|
+
});
|
|
159
|
+
// ── tool_result → PostToolUse ──────────────────────────────────────────
|
|
160
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
161
|
+
const toolCallId = event.toolCallId ?? "unknown";
|
|
162
|
+
const rateKey = `PostToolUse:${toolCallId.slice(0, 12)}`;
|
|
163
|
+
if (!shouldEmit(rateKey))
|
|
164
|
+
return {};
|
|
165
|
+
const isError = event.isError ? " (error)" : "";
|
|
166
|
+
fireObservation(`CAAMP PostToolUse — tool result received${isError}: id=${toolCallId.slice(0, 16)}`, `PostToolUse: result${isError}`, ctx.cwd);
|
|
167
|
+
hooksFired++;
|
|
168
|
+
if (ctx.hasUI) {
|
|
169
|
+
ctx.ui.setStatus("cleo-hooks-bridge", `${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`);
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
});
|
|
173
|
+
// ── before_agent_start → SubagentStart ────────────────────────────────
|
|
174
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
175
|
+
const agentName = event.agentName ??
|
|
176
|
+
event.agentDef?.name ??
|
|
177
|
+
"unknown-agent";
|
|
178
|
+
const rateKey = `SubagentStart:${agentName}`;
|
|
179
|
+
if (!shouldEmit(rateKey))
|
|
180
|
+
return {};
|
|
181
|
+
fireObservation(`CAAMP SubagentStart — Pi spawned agent: ${sanitise(agentName)}`, `SubagentStart: ${sanitise(agentName, 60)}`, ctx.cwd);
|
|
182
|
+
hooksFired++;
|
|
183
|
+
if (ctx.hasUI) {
|
|
184
|
+
ctx.ui.setStatus("cleo-hooks-bridge", `${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`);
|
|
185
|
+
}
|
|
186
|
+
// Do not modify the system prompt — other extensions handle that
|
|
187
|
+
return {};
|
|
188
|
+
});
|
|
189
|
+
// ── session_shutdown: clear state ──────────────────────────────────────
|
|
190
|
+
pi.on("session_shutdown", async () => {
|
|
191
|
+
hooksFired = 0;
|
|
192
|
+
for (const key of Object.keys(lastEmit)) {
|
|
193
|
+
delete lastEmit[key];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=cleo-hooks-bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleo-hooks-bridge.js","sourceRoot":"","sources":["cleo-hooks-bridge.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleoOS CAAMP hooks bridge — Pi event → CLEO hook translation.
|
|
3
|
+
*
|
|
4
|
+
* CANONICAL LOCATION: `packages/cleo-os/extensions/cleo-hooks-bridge.ts`
|
|
5
|
+
*
|
|
6
|
+
* Installed to: $XDG_DATA_HOME/cleo/extensions/cleo-hooks-bridge.js
|
|
7
|
+
* Loaded by: Pi via `--extension <path>` injected by CleoOS cli.ts
|
|
8
|
+
*
|
|
9
|
+
* Bridges Pi runtime events to CLEO's CAAMP hook vocabulary so that
|
|
10
|
+
* CLEO's existing hook handlers (session-hooks, task-hooks, conduit-hooks)
|
|
11
|
+
* are informed of Pi activity without requiring any changes to the hook
|
|
12
|
+
* substrate.
|
|
13
|
+
*
|
|
14
|
+
* Event → CAAMP mapping:
|
|
15
|
+
* Pi `tool_call` → `cleo memory observe` (PreToolUse)
|
|
16
|
+
* Pi `tool_result` → `cleo memory observe` (PostToolUse)
|
|
17
|
+
* Pi `before_agent_start` → `cleo memory observe` (SubagentStart)
|
|
18
|
+
*
|
|
19
|
+
* All observations are stored with `--type discovery` and `--sourceType auto`
|
|
20
|
+
* so they are distinguished from manual observations.
|
|
21
|
+
*
|
|
22
|
+
* Design constraints:
|
|
23
|
+
* - Best-effort: never crash Pi — all CLI calls wrapped in try/catch
|
|
24
|
+
* - NO top-level await; all work inside event handlers
|
|
25
|
+
* - Rate-limited: at most 1 observation per event type per 500 ms to
|
|
26
|
+
* avoid flooding brain.db with high-frequency tool calls
|
|
27
|
+
* - Tool names are sanitised before storage (no shell injection risk
|
|
28
|
+
* because we use execFileAsync, not shell)
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { promisify } from "node:util";
|
|
35
|
+
import type {
|
|
36
|
+
ExtensionAPI,
|
|
37
|
+
ExtensionContext,
|
|
38
|
+
ToolCallEvent,
|
|
39
|
+
ToolResultEvent,
|
|
40
|
+
BeforeAgentStartEvent,
|
|
41
|
+
} from "@mariozechner/pi-coding-agent";
|
|
42
|
+
import {
|
|
43
|
+
accentPrimary,
|
|
44
|
+
textSecondary,
|
|
45
|
+
ICON_FORGE,
|
|
46
|
+
} from "./tui-theme.js";
|
|
47
|
+
|
|
48
|
+
const execFileAsync = promisify(execFile);
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Rate-limiting state
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Minimum milliseconds between observations of the same hook type.
|
|
56
|
+
* Prevents flooding brain.db on high-frequency tool invocations.
|
|
57
|
+
*/
|
|
58
|
+
const RATE_LIMIT_MS = 500;
|
|
59
|
+
|
|
60
|
+
/** Tracks the last emission timestamp per hook type key. */
|
|
61
|
+
const lastEmit: Record<string, number> = {};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check whether a hook observation should be emitted, applying rate limiting.
|
|
65
|
+
*
|
|
66
|
+
* @param key - A string key identifying the hook type (e.g. "PreToolUse:bash").
|
|
67
|
+
* @returns `true` if the observation should be emitted now.
|
|
68
|
+
*/
|
|
69
|
+
function shouldEmit(key: string): boolean {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const last = lastEmit[key] ?? 0;
|
|
72
|
+
if (now - last < RATE_LIMIT_MS) return false;
|
|
73
|
+
lastEmit[key] = now;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// CAAMP observation helpers
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Sanitise a string for safe inclusion in a CLEO observation title/text.
|
|
83
|
+
*
|
|
84
|
+
* Trims and limits length to avoid oversized brain.db entries.
|
|
85
|
+
*
|
|
86
|
+
* @param raw - The raw string to sanitise.
|
|
87
|
+
* @param maxLen - Maximum character length (default 120).
|
|
88
|
+
* @returns The sanitised string.
|
|
89
|
+
*/
|
|
90
|
+
function sanitise(raw: string, maxLen = 120): string {
|
|
91
|
+
return raw.trim().slice(0, maxLen);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract a human-readable summary from a `tool_call` event.
|
|
96
|
+
*
|
|
97
|
+
* @param event - The Pi tool_call event.
|
|
98
|
+
* @returns A brief description of the tool invocation.
|
|
99
|
+
*/
|
|
100
|
+
function summariseToolCall(event: ToolCallEvent): string {
|
|
101
|
+
const toolName = event.toolName ?? "unknown";
|
|
102
|
+
if (toolName === "bash") {
|
|
103
|
+
const cmd = (event as { input?: { command?: string } }).input?.command ?? "";
|
|
104
|
+
return `bash: ${sanitise(cmd, 80)}`;
|
|
105
|
+
}
|
|
106
|
+
if (toolName === "edit" || toolName === "write" || toolName === "read") {
|
|
107
|
+
const path = (event as { input?: { file_path?: string } }).input?.file_path ?? "";
|
|
108
|
+
return `${toolName}: ${sanitise(path, 80)}`;
|
|
109
|
+
}
|
|
110
|
+
return toolName;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fire a CAAMP observation via `cleo memory observe`.
|
|
115
|
+
*
|
|
116
|
+
* Best-effort: any error is silently swallowed to never crash Pi.
|
|
117
|
+
*
|
|
118
|
+
* @param text - Observation text to store.
|
|
119
|
+
* @param title - Short title for the observation.
|
|
120
|
+
* @param cwd - Project root directory.
|
|
121
|
+
*/
|
|
122
|
+
function fireObservation(text: string, title: string, cwd: string): void {
|
|
123
|
+
execFileAsync(
|
|
124
|
+
"cleo",
|
|
125
|
+
[
|
|
126
|
+
"memory", "observe",
|
|
127
|
+
"--title", sanitise(title, 120),
|
|
128
|
+
"--type", "discovery",
|
|
129
|
+
"--sourceType", "auto",
|
|
130
|
+
"--agent", "pi-hooks-bridge",
|
|
131
|
+
sanitise(text, 500),
|
|
132
|
+
],
|
|
133
|
+
{ timeout: 5_000, cwd },
|
|
134
|
+
).catch(() => {
|
|
135
|
+
// Intentionally swallowed — best-effort only
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// Status bar tracking
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
/** Count of hooks fired this session for the status bar display. */
|
|
144
|
+
let hooksFired = 0;
|
|
145
|
+
|
|
146
|
+
// ============================================================================
|
|
147
|
+
// Pi extension factory
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pi extension factory for the CAAMP hooks bridge.
|
|
152
|
+
*
|
|
153
|
+
* Registers:
|
|
154
|
+
* - `tool_call` → PreToolUse observation
|
|
155
|
+
* - `tool_result` → PostToolUse observation
|
|
156
|
+
* - `before_agent_start` → SubagentStart observation
|
|
157
|
+
*
|
|
158
|
+
* @param pi - The Pi extension API instance.
|
|
159
|
+
*/
|
|
160
|
+
export default function (pi: ExtensionAPI): void {
|
|
161
|
+
// ── session_start: reset counters ──────────────────────────────────────
|
|
162
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
163
|
+
hooksFired = 0;
|
|
164
|
+
// Clear rate-limit state for fresh session
|
|
165
|
+
for (const key of Object.keys(lastEmit)) {
|
|
166
|
+
delete lastEmit[key];
|
|
167
|
+
}
|
|
168
|
+
if (ctx.hasUI) {
|
|
169
|
+
ctx.ui.setStatus(
|
|
170
|
+
"cleo-hooks-bridge",
|
|
171
|
+
`${accentPrimary(ICON_FORGE)} hooks: 0`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── tool_call → PreToolUse ─────────────────────────────────────────────
|
|
177
|
+
pi.on(
|
|
178
|
+
"tool_call",
|
|
179
|
+
async (event: ToolCallEvent, ctx: ExtensionContext) => {
|
|
180
|
+
const toolName = event.toolName ?? "unknown";
|
|
181
|
+
const rateKey = `PreToolUse:${toolName}`;
|
|
182
|
+
|
|
183
|
+
if (!shouldEmit(rateKey)) return {};
|
|
184
|
+
|
|
185
|
+
const summary = summariseToolCall(event);
|
|
186
|
+
fireObservation(
|
|
187
|
+
`CAAMP PreToolUse — Pi invoked tool: ${summary}`,
|
|
188
|
+
`PreToolUse: ${toolName}`,
|
|
189
|
+
ctx.cwd,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
hooksFired++;
|
|
193
|
+
if (ctx.hasUI) {
|
|
194
|
+
ctx.ui.setStatus(
|
|
195
|
+
"cleo-hooks-bridge",
|
|
196
|
+
`${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Do not modify the tool call
|
|
201
|
+
return {};
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ── tool_result → PostToolUse ──────────────────────────────────────────
|
|
206
|
+
pi.on(
|
|
207
|
+
"tool_result",
|
|
208
|
+
async (event: ToolResultEvent, ctx: ExtensionContext) => {
|
|
209
|
+
const toolCallId = event.toolCallId ?? "unknown";
|
|
210
|
+
const rateKey = `PostToolUse:${toolCallId.slice(0, 12)}`;
|
|
211
|
+
|
|
212
|
+
if (!shouldEmit(rateKey)) return {};
|
|
213
|
+
|
|
214
|
+
const isError = event.isError ? " (error)" : "";
|
|
215
|
+
fireObservation(
|
|
216
|
+
`CAAMP PostToolUse — tool result received${isError}: id=${toolCallId.slice(0, 16)}`,
|
|
217
|
+
`PostToolUse: result${isError}`,
|
|
218
|
+
ctx.cwd,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
hooksFired++;
|
|
222
|
+
if (ctx.hasUI) {
|
|
223
|
+
ctx.ui.setStatus(
|
|
224
|
+
"cleo-hooks-bridge",
|
|
225
|
+
`${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {};
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// ── before_agent_start → SubagentStart ────────────────────────────────
|
|
234
|
+
pi.on(
|
|
235
|
+
"before_agent_start",
|
|
236
|
+
async (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
|
|
237
|
+
const agentName =
|
|
238
|
+
(event as { agentName?: string }).agentName ??
|
|
239
|
+
(event as { agentDef?: { name?: string } }).agentDef?.name ??
|
|
240
|
+
"unknown-agent";
|
|
241
|
+
|
|
242
|
+
const rateKey = `SubagentStart:${agentName}`;
|
|
243
|
+
|
|
244
|
+
if (!shouldEmit(rateKey)) return {};
|
|
245
|
+
|
|
246
|
+
fireObservation(
|
|
247
|
+
`CAAMP SubagentStart — Pi spawned agent: ${sanitise(agentName)}`,
|
|
248
|
+
`SubagentStart: ${sanitise(agentName, 60)}`,
|
|
249
|
+
ctx.cwd,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
hooksFired++;
|
|
253
|
+
if (ctx.hasUI) {
|
|
254
|
+
ctx.ui.setStatus(
|
|
255
|
+
"cleo-hooks-bridge",
|
|
256
|
+
`${textSecondary(ICON_FORGE)} hooks: ${hooksFired}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Do not modify the system prompt — other extensions handle that
|
|
261
|
+
return {};
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// ── session_shutdown: clear state ──────────────────────────────────────
|
|
266
|
+
pi.on("session_shutdown", async () => {
|
|
267
|
+
hooksFired = 0;
|
|
268
|
+
for (const key of Object.keys(lastEmit)) {
|
|
269
|
+
delete lastEmit[key];
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|