Skip to content

Commit ee206d5

Browse files
feat(events): promote lifecycle observation families (#258 v2 s4) (#359)
1 parent 8c541ad commit ee206d5

18 files changed

Lines changed: 308 additions & 2 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
Promote the `file/change`, `config/change`, `task/create`, `task/complete`, and `agent/idle` canonical event-route families as Claude-only capabilities with their documented decision channels: `config/change` and `task/create` project deny as a top-level block decision, `agent/idle` projects deny as `continue: false` with a stop reason, while `file/change` and `task/complete` are observation-only with fail-closed errors explaining the host's side-effect-only and exit-code-only control models. Codex, Cursor, and portable carry dated `unavailable` rows per family.

‎examples/rsc-agent-runtime/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the
241241
| `permission/denied` | Unavailable | `PermissionDenied` (observe-only) | Unavailable |
242242
| `stop` | Supported | `Stop` | `Stop` |
243243
| `stop/failure` | Unavailable | `StopFailure` (observe-only) | Unavailable |
244+
| `file/change` | Unavailable (agent-edit `afterFileEdit` stays a `tool/after` variant) | `FileChanged` (observe-only) | Unavailable |
245+
| `config/change` | Unavailable | `ConfigChange` (deny) | Unavailable |
246+
| `task/create` | Unavailable | `TaskCreated` (deny) | Unavailable |
247+
| `task/complete` | Unavailable | `TaskCompleted` (observe-only; blocking is exit-code-only) | Unavailable |
248+
| `agent/idle` | Unavailable | `TeammateIdle` (deny via continue:false) | Unavailable |
244249
| `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` |
245250
| `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` |
246251
| `workspace/open` | Supported (observe-only; native `pluginPaths` return not modeled) | Unavailable | Unavailable |

‎packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,22 @@
2323
"stop": "Stop"
2424
},
2525
"eventRoutes": {
26+
"agent/idle": { "nativeEvent": "TeammateIdle", "state": "supported" },
2627
"agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
2728
"agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
2829
"compact/after": { "nativeEvent": "PostCompact", "state": "supported" },
2930
"compact/before": { "nativeEvent": "PreCompact", "state": "supported" },
31+
"config/change": { "nativeEvent": "ConfigChange", "state": "supported" },
32+
"file/change": { "nativeEvent": "FileChanged", "state": "supported" },
3033
"permission/denied": { "nativeEvent": "PermissionDenied", "state": "supported" },
3134
"permission/request": { "nativeEvent": "PermissionRequest", "state": "supported" },
3235
"prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" },
3336
"session/end": { "nativeEvent": "SessionEnd", "state": "supported" },
3437
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
3538
"stop": { "nativeEvent": "Stop", "state": "supported" },
3639
"stop/failure": { "nativeEvent": "StopFailure", "state": "supported" },
40+
"task/complete": { "nativeEvent": "TaskCompleted", "state": "supported" },
41+
"task/create": { "nativeEvent": "TaskCreated", "state": "supported" },
3742
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
3843
"tool/before": { "nativeEvent": "PreToolUse", "state": "supported" },
3944
"tool/failure": { "nativeEvent": "PostToolUseFailure", "state": "supported" },

‎packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,22 @@
2323
"stop": "Stop"
2424
},
2525
"eventRoutes": {
26+
"agent/idle": {
27+
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no teammate-idle event.",
28+
"state": "unavailable"
29+
},
2630
"agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
2731
"agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
2832
"compact/after": { "nativeEvent": "PostCompact", "state": "supported" },
2933
"compact/before": { "nativeEvent": "PreCompact", "state": "supported" },
34+
"config/change": {
35+
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no config-change event.",
36+
"state": "unavailable"
37+
},
38+
"file/change": {
39+
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no file-changed event.",
40+
"state": "unavailable"
41+
},
3042
"permission/denied": {
3143
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no permission-denied event.",
3244
"state": "unavailable"
@@ -40,6 +52,14 @@
4052
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no stop-failure event.",
4153
"state": "unavailable"
4254
},
55+
"task/complete": {
56+
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no task-completed event.",
57+
"state": "unavailable"
58+
},
59+
"task/create": {
60+
"reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no task-created event.",
61+
"state": "unavailable"
62+
},
4363
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
4464
"tool/before": { "nativeEvent": "PreToolUse", "state": "supported" },
4565
"tool/failure": {

‎packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,25 @@
2323
"stop": "stop"
2424
},
2525
"eventRoutes": {
26+
"agent/idle": {
27+
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no teammate-idle event.",
28+
"state": "unavailable"
29+
},
2630
"agent/start": { "nativeEvent": "subagentStart", "state": "supported" },
2731
"agent/stop": { "nativeEvent": "subagentStop", "state": "supported" },
2832
"compact/after": {
2933
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no postCompact event.",
3034
"state": "unavailable"
3135
},
3236
"compact/before": { "nativeEvent": "preCompact", "state": "supported" },
37+
"config/change": {
38+
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no configChange event.",
39+
"state": "unavailable"
40+
},
41+
"file/change": {
42+
"reason": "retrieved 2026-09-02: Cursor documents afterFileEdit as an agent-edit tool variant that overlaps canonical tool/after per the #258 selector rule, not a disk-watch FileChanged equivalent; no dedicated family is inferred without fixture evidence of loss.",
43+
"state": "unavailable"
44+
},
3345
"permission/denied": {
3446
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no permission-denied event.",
3547
"state": "unavailable"
@@ -56,6 +68,14 @@
5668
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no stop-failure event.",
5769
"state": "unavailable"
5870
},
71+
"task/complete": {
72+
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no taskCompleted event.",
73+
"state": "unavailable"
74+
},
75+
"task/create": {
76+
"reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no taskCreated event.",
77+
"state": "unavailable"
78+
},
5979
"tool/after": { "nativeEvent": "postToolUse", "state": "supported" },
6080
"tool/before": { "nativeEvent": "preToolUse", "state": "supported" },
6181
"tool/failure": { "nativeEvent": "postToolUseFailure", "state": "supported" },

‎packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"state": "unavailable"
1111
},
1212
"eventRoutes": {
13+
"agent/idle": {
14+
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native TeammateIdle equivalent (https://agent-plugins.org/).",
15+
"state": "unavailable"
16+
},
1317
"agent/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
1418
"agent/stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
1519
"compact/after": {
@@ -20,6 +24,14 @@
2024
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PreCompact/preCompact equivalent (https://agent-plugins.org/).",
2125
"state": "unavailable"
2226
},
27+
"config/change": {
28+
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native ConfigChange equivalent (https://agent-plugins.org/).",
29+
"state": "unavailable"
30+
},
31+
"file/change": {
32+
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native FileChanged equivalent (https://agent-plugins.org/).",
33+
"state": "unavailable"
34+
},
2335
"permission/denied": {
2436
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PermissionDenied equivalent (https://agent-plugins.org/).",
2537
"state": "unavailable"
@@ -42,6 +54,14 @@
4254
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native StopFailure equivalent (https://agent-plugins.org/).",
4355
"state": "unavailable"
4456
},
57+
"task/complete": {
58+
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native TaskCompleted equivalent (https://agent-plugins.org/).",
59+
"state": "unavailable"
60+
},
61+
"task/create": {
62+
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native TaskCreated equivalent (https://agent-plugins.org/).",
63+
"state": "unavailable"
64+
},
4565
"tool/after": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
4666
"tool/before": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
4767
"tool/failure": {

‎packages/agent-bundle/src/adapters/hook-contract.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,30 @@ export const createNativeEventStarter = (
255255
last_assistant_message: null,
256256
stop_hook_active: false,
257257
});
258+
case 'file/change':
259+
return deepFreeze({ ...base, file_path: '/tmp/lifecycle-replay-watched.txt' });
260+
case 'config/change':
261+
return deepFreeze({ ...base, file_path: '/tmp/lifecycle-replay-settings.json', source: 'project_settings' });
262+
case 'task/create':
263+
return deepFreeze({
264+
...base,
265+
task_id: 'lifecycle-replay-task',
266+
task_subject: 'Lifecycle replay task.',
267+
});
268+
case 'task/complete':
269+
return deepFreeze({
270+
...base,
271+
permission_mode: 'default',
272+
task_id: 'lifecycle-replay-task',
273+
task_subject: 'Lifecycle replay task.',
274+
});
275+
case 'agent/idle':
276+
return deepFreeze({
277+
...base,
278+
permission_mode: 'default',
279+
team_name: 'lifecycle-replay-team',
280+
teammate_name: 'lifecycle-replay-teammate',
281+
});
258282
case 'workspace/open':
259283
return deepFreeze(target === 'cursor'
260284
? {

‎packages/agent-bundle/src/config/normalize.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ const hookEventForRoute: Readonly<Record<CanonicalAgentEvent, NormalizedHookEven
116116
'permission/request': 'permissionRequest',
117117
'permission/denied': 'permissionDenied',
118118
'stop/failure': 'stopFailure',
119+
'file/change': 'fileChange',
120+
'config/change': 'configChange',
121+
'task/create': 'taskCreate',
122+
'task/complete': 'taskComplete',
123+
'agent/idle': 'agentIdle',
119124
'workspace/open': 'workspaceOpen',
120125
});
121126

‎packages/agent-bundle/src/core/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export const eventRouteOnlyHookEvents = Object.freeze([
4949
'permissionRequest',
5050
'permissionDenied',
5151
'stopFailure',
52+
'fileChange',
53+
'configChange',
54+
'taskCreate',
55+
'taskComplete',
56+
'agentIdle',
5257
] as const);
5358

5459
export type EventRouteOnlyHookEvent = (typeof eventRouteOnlyHookEvents)[number];

‎packages/agent-bundle/src/events/projection.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,24 @@ export const validateNativeEventEnvelope = (
246246
return nativeEventError('native stop_hook_active must be a boolean');
247247
}
248248
}
249+
if (canonicalEvent === 'file/change') requireNativeString(native, 'file_path');
250+
if (canonicalEvent === 'config/change') {
251+
if (!['user_settings', 'project_settings', 'local_settings', 'policy_settings', 'skills'].includes(String(native.source))) {
252+
return nativeEventError('native source is invalid');
253+
}
254+
if (Object.hasOwn(native, 'file_path')) requireNativeString(native, 'file_path');
255+
}
256+
if (canonicalEvent === 'task/create' || canonicalEvent === 'task/complete') {
257+
requireNativeString(native, 'task_id');
258+
requireNativeString(native, 'task_subject');
259+
for (const field of ['task_description', 'teammate_name', 'team_name']) {
260+
if (Object.hasOwn(native, field)) requireNativeString(native, field);
261+
}
262+
}
263+
if (canonicalEvent === 'agent/idle') {
264+
requireNativeString(native, 'teammate_name');
265+
requireNativeString(native, 'team_name');
266+
}
249267
if (canonicalEvent === 'compact/before' || canonicalEvent === 'compact/after') {
250268
requireCompactTrigger(native);
251269
if (target === 'codex') {
@@ -574,6 +592,60 @@ export const projectEventDocument = (
574592
}
575593
return undefined;
576594
}
595+
if (event === 'file/change') {
596+
if (
597+
parsedValue?.outcome === 'deny'
598+
|| parsedValue?.reason !== undefined
599+
|| parsedValue?.updatedInput !== undefined
600+
) {
601+
throw new TypeError('file/change has no decision control on Claude FileChanged; it is side-effect-only.');
602+
}
603+
if (additionalContext !== undefined) {
604+
throw new TypeError('file/change has no documented context/output channel.');
605+
}
606+
return undefined;
607+
}
608+
if (event === 'config/change' || event === 'task/create') {
609+
if (parsedValue?.updatedInput !== undefined) {
610+
throw new TypeError(`${event} cannot replace native input.`);
611+
}
612+
if (additionalContext !== undefined) {
613+
throw new TypeError(`${event} has no documented additional-context channel.`);
614+
}
615+
if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') {
616+
throw new TypeError(`${event} reason is only valid when outcome is deny.`);
617+
}
618+
return parsedValue?.outcome === 'deny'
619+
? Object.freeze({ decision: 'block', reason: requireDenyReason() })
620+
: undefined;
621+
}
622+
if (event === 'task/complete') {
623+
if (
624+
parsedValue?.outcome === 'deny'
625+
|| parsedValue?.reason !== undefined
626+
|| parsedValue?.updatedInput !== undefined
627+
) {
628+
throw new TypeError('task/complete blocking is exit-code-only on Claude TaskCompleted (JSON continue:false redirects to teammate stop and is ignored for TaskUpdate); it is not projected.');
629+
}
630+
if (additionalContext !== undefined) {
631+
throw new TypeError('task/complete has no documented context/output channel.');
632+
}
633+
return undefined;
634+
}
635+
if (event === 'agent/idle') {
636+
if (parsedValue?.updatedInput !== undefined) {
637+
throw new TypeError('agent/idle cannot replace native input.');
638+
}
639+
if (additionalContext !== undefined) {
640+
throw new TypeError('agent/idle has no documented additional-context channel.');
641+
}
642+
if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') {
643+
throw new TypeError('agent/idle reason is only valid when outcome is deny.');
644+
}
645+
return parsedValue?.outcome === 'deny'
646+
? Object.freeze({ continue: false, stopReason: requireDenyReason() })
647+
: undefined;
648+
}
577649
if (event === 'tool/before') {
578650
if (target === 'cursor') {
579651
if (parsedValue?.outcome === 'deny') {

0 commit comments

Comments
 (0)