@aragon-agent/cli 0.6.38 → 0.6.39

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/registry.d.ts +39 -0
  14. package/dist/commands/task-command.d.ts +39 -0
  15. package/dist/commands/task-command.js +102 -0
  16. package/dist/config/cli-commands.d.ts +23 -0
  17. package/dist/config/cli-commands.js +36 -0
  18. package/dist/config/env.js +17 -4
  19. package/dist/config/load.js +6 -1
  20. package/dist/config/schema.d.ts +43 -4
  21. package/dist/config/schema.js +31 -5
  22. package/dist/config/store.js +17 -2
  23. package/dist/diagnostics/doctor.d.ts +9 -0
  24. package/dist/diagnostics/doctor.js +34 -0
  25. package/dist/diagnostics/info.d.ts +71 -0
  26. package/dist/diagnostics/info.js +91 -5
  27. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  28. package/dist/exec/bridge/caller-tools.js +213 -0
  29. package/dist/exec/bridge/decisions.d.ts +78 -0
  30. package/dist/exec/bridge/decisions.js +116 -0
  31. package/dist/exec/bridge/human-answers.d.ts +38 -0
  32. package/dist/exec/bridge/human-answers.js +164 -0
  33. package/dist/exec/bridge/limits.d.ts +68 -0
  34. package/dist/exec/bridge/limits.js +68 -0
  35. package/dist/exec/bridge/ndjson.d.ts +17 -0
  36. package/dist/exec/bridge/ndjson.js +83 -0
  37. package/dist/exec/bridge/pending.d.ts +70 -0
  38. package/dist/exec/bridge/pending.js +132 -0
  39. package/dist/exec/bridge/protocol.d.ts +346 -0
  40. package/dist/exec/bridge/protocol.js +256 -0
  41. package/dist/exec/bridge/server.d.ts +142 -0
  42. package/dist/exec/bridge/server.js +502 -0
  43. package/dist/exec/bridge/source.d.ts +58 -0
  44. package/dist/exec/bridge/source.js +145 -0
  45. package/dist/exec/bridge/writer.d.ts +26 -0
  46. package/dist/exec/bridge/writer.js +156 -0
  47. package/dist/exec/cli-commands.js +30 -2
  48. package/dist/exec/emitter.d.ts +16 -2
  49. package/dist/exec/emitter.js +26 -6
  50. package/dist/exec/events.d.ts +465 -10
  51. package/dist/exec/events.js +47 -1
  52. package/dist/exec/hooks/dispatch.d.ts +76 -0
  53. package/dist/exec/hooks/dispatch.js +169 -0
  54. package/dist/exec/hooks/index.d.ts +83 -0
  55. package/dist/exec/hooks/index.js +205 -0
  56. package/dist/exec/hooks/limits.d.ts +56 -0
  57. package/dist/exec/hooks/limits.js +49 -0
  58. package/dist/exec/hooks/registry.d.ts +49 -0
  59. package/dist/exec/hooks/registry.js +91 -0
  60. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  61. package/dist/exec/hooks/tool-wrapper.js +154 -0
  62. package/dist/exec/hooks/turn.d.ts +61 -0
  63. package/dist/exec/hooks/turn.js +59 -0
  64. package/dist/exec/hooks/validate.d.ts +85 -0
  65. package/dist/exec/hooks/validate.js +310 -0
  66. package/dist/exec/index.d.ts +130 -4
  67. package/dist/exec/index.js +501 -10
  68. package/dist/exec/options.d.ts +124 -2
  69. package/dist/exec/options.js +413 -6
  70. package/dist/exec/permission.d.ts +9 -1
  71. package/dist/exec/permission.js +7 -1
  72. package/dist/exec/request/limits.d.ts +51 -0
  73. package/dist/exec/request/limits.js +49 -0
  74. package/dist/exec/request/scope.d.ts +106 -0
  75. package/dist/exec/request/scope.js +149 -0
  76. package/dist/exec/result/limits.d.ts +74 -0
  77. package/dist/exec/result/limits.js +72 -0
  78. package/dist/exec/result/prompt.d.ts +28 -0
  79. package/dist/exec/result/prompt.js +74 -0
  80. package/dist/exec/result/schema.d.ts +99 -0
  81. package/dist/exec/result/schema.js +464 -0
  82. package/dist/exec/result/sink.d.ts +56 -0
  83. package/dist/exec/result/sink.js +50 -0
  84. package/dist/exec/result/tool.d.ts +37 -0
  85. package/dist/exec/result/tool.js +118 -0
  86. package/dist/exec/runner.d.ts +395 -1
  87. package/dist/exec/runner.js +860 -8
  88. package/dist/exec/threads/aggregate.d.ts +34 -0
  89. package/dist/exec/threads/aggregate.js +30 -0
  90. package/dist/exec/threads/capacity.d.ts +23 -0
  91. package/dist/exec/threads/capacity.js +38 -0
  92. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  93. package/dist/exec/threads/dispatch-source.js +106 -0
  94. package/dist/exec/threads/execute-threads.d.ts +53 -0
  95. package/dist/exec/threads/execute-threads.js +234 -0
  96. package/dist/exec/threads/gate.d.ts +42 -0
  97. package/dist/exec/threads/gate.js +81 -0
  98. package/dist/exec/threads/identity.d.ts +36 -0
  99. package/dist/exec/threads/identity.js +67 -0
  100. package/dist/exec/threads/interrupt.d.ts +29 -0
  101. package/dist/exec/threads/interrupt.js +79 -0
  102. package/dist/exec/threads/limits.d.ts +58 -0
  103. package/dist/exec/threads/limits.js +35 -0
  104. package/dist/exec/threads/outcome.d.ts +47 -0
  105. package/dist/exec/threads/outcome.js +26 -0
  106. package/dist/exec/threads/registry.d.ts +81 -0
  107. package/dist/exec/threads/registry.js +427 -0
  108. package/dist/exec/threads/runtime.d.ts +107 -0
  109. package/dist/exec/threads/runtime.js +523 -0
  110. package/dist/exec/threads/thread-source.d.ts +20 -0
  111. package/dist/exec/threads/thread-source.js +134 -0
  112. package/dist/fast/limits.d.ts +54 -0
  113. package/dist/fast/limits.js +54 -0
  114. package/dist/fast/summary-call.d.ts +72 -0
  115. package/dist/fast/summary-call.js +133 -0
  116. package/dist/fast/summary-prompt.d.ts +106 -0
  117. package/dist/fast/summary-prompt.js +320 -0
  118. package/dist/fast/task-summary-types.d.ts +66 -0
  119. package/dist/fast/task-summary-types.js +17 -0
  120. package/dist/logging/logger.d.ts +1 -1
  121. package/dist/proc/limits.d.ts +7 -0
  122. package/dist/proc/limits.js +7 -0
  123. package/dist/proc/output-streams.d.ts +50 -0
  124. package/dist/proc/output-streams.js +54 -0
  125. package/dist/team/runtime.d.ts +9 -0
  126. package/dist/team/runtime.js +8 -0
  127. package/dist/team/subagent.d.ts +15 -0
  128. package/dist/team/subagent.js +45 -1
  129. package/dist/tools/bash-tool.d.ts +10 -0
  130. package/dist/tools/bash-tool.js +71 -6
  131. package/dist/tools/index.d.ts +93 -1
  132. package/dist/tools/index.js +112 -3
  133. package/dist/tools/tool-gate.d.ts +100 -0
  134. package/dist/tools/tool-gate.js +87 -0
  135. package/dist/ui/App.js +224 -11
  136. package/dist/ui/Header.d.ts +28 -16
  137. package/dist/ui/Header.js +21 -19
  138. package/dist/ui/interaction-hints.js +6 -0
  139. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  140. package/dist/ui/layout/OverlayFrame.js +19 -4
  141. package/dist/ui/layout/header-hints.d.ts +55 -62
  142. package/dist/ui/layout/header-hints.js +59 -166
  143. package/dist/ui/layout/header-left.d.ts +115 -0
  144. package/dist/ui/layout/header-left.js +206 -0
  145. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  146. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  147. package/dist/ui/layout/status-detail-layout.js +5 -1
  148. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  149. package/dist/ui/layout/task-summary-cell.js +65 -0
  150. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  151. package/dist/ui/overlays/HelpOverlay.js +29 -2
  152. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  153. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  154. package/dist/ui/shortcuts.d.ts +81 -0
  155. package/dist/ui/shortcuts.js +221 -0
  156. package/package.json +2 -2
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The hook table: registration, id assignment and chain lookup
3
+ * (headless-threads-and-lifecycle-hooks section 2.4.3).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * CHAINS, NOT FIRST-MATCH. Several hooks on one (point x tool) run in registration
8
+ * order and each sees the previous one's output. That is deliberate: redaction and
9
+ * tenant-scoping are routinely registered by two different teams, and "first match wins"
10
+ * would turn the order they were installed into an invisible security contract.
11
+ *
12
+ * ONE INSTANCE PER THREAD. This object is owned by `ThreadRuntime`, and the
13
+ * `ToolHookRunner` built from it is handed to THAT thread's `createBuiltinTools`. There
14
+ * is no path from thread A's runner to thread B's registry that does not go through a
15
+ * shared reference this class does not have - which is what makes I-H9 ownership rather
16
+ * than an assertion.
17
+ */
18
+ import { type HookPoint } from './limits.js';
19
+ import { type ValidatedHookSpec } from './validate.js';
20
+ export interface HookSpec extends ValidatedHookSpec {
21
+ /** `hook_request.hookId`: `h1`, `h2`, ... strictly increasing within the thread. */
22
+ id: string;
23
+ }
24
+ export type HookRegisterResult = {
25
+ ok: true;
26
+ hooks: HookSpec[];
27
+ warnings: string[];
28
+ } | {
29
+ ok: false;
30
+ code: string;
31
+ message: string;
32
+ };
33
+ export interface HookRegistry {
34
+ /** Whole-frame registration: all specs or none. */
35
+ register(raw: readonly unknown[]): HookRegisterResult;
36
+ /** The chain for one (point x tool), in registration order. */
37
+ chainFor(point: HookPoint, tool?: string): readonly HookSpec[];
38
+ /** True when any hook is registered for this point (the fast path for the wrapper). */
39
+ hasPoint(point: HookPoint): boolean;
40
+ count(): number;
41
+ ids(): string[];
42
+ /** The spec with this id, or `undefined` (used by the dispatcher's `unregistered`). */
43
+ get(id: string): HookSpec | undefined;
44
+ }
45
+ export interface HookRegistryOptions {
46
+ /** Names a hook may name: host tools plus this thread's caller tools. */
47
+ knownTools: () => ReadonlySet<string>;
48
+ }
49
+ export declare function createHookRegistry(options: HookRegistryOptions): HookRegistry;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The hook table: registration, id assignment and chain lookup
3
+ * (headless-threads-and-lifecycle-hooks section 2.4.3).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * CHAINS, NOT FIRST-MATCH. Several hooks on one (point x tool) run in registration
8
+ * order and each sees the previous one's output. That is deliberate: redaction and
9
+ * tenant-scoping are routinely registered by two different teams, and "first match wins"
10
+ * would turn the order they were installed into an invisible security contract.
11
+ *
12
+ * ONE INSTANCE PER THREAD. This object is owned by `ThreadRuntime`, and the
13
+ * `ToolHookRunner` built from it is handed to THAT thread's `createBuiltinTools`. There
14
+ * is no path from thread A's runner to thread B's registry that does not go through a
15
+ * shared reference this class does not have - which is what makes I-H9 ownership rather
16
+ * than an assertion.
17
+ */
18
+ import { HOOK_LIMITS } from './limits.js';
19
+ import { validateHookSpecs } from './validate.js';
20
+ export function createHookRegistry(options) {
21
+ const hooks = [];
22
+ let nextId = 0;
23
+ return {
24
+ register(raw) {
25
+ // VALIDATE FIRST, THEN COUNT: the two capacity refusals below must be decided on a
26
+ // set that is already well formed, or a frame with one bad spec would be reported
27
+ // as "too many hooks" and the caller would go looking for the wrong problem.
28
+ const validated = validateHookSpecs(raw, { knownTools: options.knownTools() });
29
+ if (!validated.ok)
30
+ return { ok: false, code: validated.code, message: validated.message };
31
+ if (hooks.length + validated.specs.length > HOOK_LIMITS.maxHooks) {
32
+ return {
33
+ ok: false,
34
+ code: 'too_many_hooks',
35
+ message: `This thread already holds ${hooks.length} hooks; the ceiling is ` +
36
+ `${HOOK_LIMITS.maxHooks}.`,
37
+ };
38
+ }
39
+ // THE PER-POINT CEILING IS CHECKED AGAINST WHAT THE FRAME WOULD ADD, pairwise, so
40
+ // the refusal names the real limit rather than an aggregate.
41
+ for (const spec of validated.specs) {
42
+ for (const tool of spec.tools) {
43
+ const existing = chainLength(hooks, spec.point, tool);
44
+ // `'*'` COUNTS, exactly as it does in `matches`: counting only an explicit tool
45
+ // name let one frame of four wildcards plus four named specs pass validation (each
46
+ // naming a different tool) and then run an eight-long chain past a limit of four.
47
+ const incoming = validated.specs.filter((other) => other.point === spec.point && matches(other, spec.point, tool)).length;
48
+ if (existing + incoming > HOOK_LIMITS.maxHooksPerPoint) {
49
+ return {
50
+ ok: false,
51
+ code: 'too_many_hooks',
52
+ message: `${spec.point} x ${tool} would hold ${existing + incoming} hooks; the ` +
53
+ `ceiling is ${HOOK_LIMITS.maxHooksPerPoint}.`,
54
+ };
55
+ }
56
+ }
57
+ }
58
+ const adopted = validated.specs.map((spec) => {
59
+ nextId += 1;
60
+ return { ...spec, id: `h${nextId}` };
61
+ });
62
+ hooks.push(...adopted);
63
+ return { ok: true, hooks: adopted, warnings: validated.warnings };
64
+ },
65
+ chainFor(point, tool) {
66
+ return hooks.filter((hook) => hook.point === point && matches(hook, point, tool));
67
+ },
68
+ hasPoint: (point) => hooks.some((hook) => hook.point === point),
69
+ count: () => hooks.length,
70
+ ids: () => hooks.map((hook) => hook.id),
71
+ get: (id) => hooks.find((hook) => hook.id === id),
72
+ };
73
+ }
74
+ /**
75
+ * `tools` ONLY MEANS SOMETHING ON THE TOOL POINTS.
76
+ *
77
+ * A `prompt_pre` hook that names `tools: ['read_file']` is not an error - it is a hook
78
+ * whose filter has no subject - and refusing it would make a caller write two code paths
79
+ * for one table. `'*'` matches everything, and an absent entry never reaches here (the
80
+ * validator defaults it to `['*']`).
81
+ */
82
+ function matches(hook, point, tool) {
83
+ if (point !== 'tool_pre' && point !== 'tool_post')
84
+ return true;
85
+ if (tool === undefined)
86
+ return true;
87
+ return hook.tools.includes('*') || hook.tools.includes(tool);
88
+ }
89
+ function chainLength(hooks, point, tool) {
90
+ return hooks.filter((hook) => hook.point === point && matches(hook, point, tool)).length;
91
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `tool_pre` and `tool_post`: the two hook points that sit in the tool wrapper chain
3
+ * (headless-threads-and-lifecycle-hooks section 2.4.4 / 2.4.5 / 2.4.9).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE PLACEMENT IS THE SECURITY PROPERTY, so it is argued here rather than in a diagram
8
+ * that can drift:
9
+ *
10
+ * tool_post <- outermost: covers everything below, including denial text
11
+ * jobNotices
12
+ * planGate
13
+ * skillCeiling
14
+ * tool_pre <- inside the two policy gates, outside the approval gate
15
+ * approvalGate
16
+ * confirmation
17
+ * the tool itself
18
+ *
19
+ * `tool_post` IS OUTSIDE `<job_notices>` because a background job's notice tail carries
20
+ * the LAST FEW LINES OF ITS RAW OUTPUT (`proc/notices.ts`). A `bash background:true`
21
+ * that printed a key would otherwise smuggle it into the model's view past any redactor
22
+ * installed inside that layer - which is precisely the case the feature exists for.
23
+ *
24
+ * `tool_pre` IS OUTSIDE `approvalGate` so a human approves the REWRITTEN arguments. The
25
+ * opposite order makes "approved A, ran B" possible, which is a defect rather than a
26
+ * style choice. It is INSIDE planGate / skillCeiling, which refuse without I/O: paying a
27
+ * round trip to ask about a tool that policy already refuses is a worse trade, and the
28
+ * trade-off is recorded as I-H4b so it is not "fixed" by accident.
29
+ *
30
+ * VERDICTS ARE TOOL RESULTS, NEVER THROWS. `tool-gate.ts` records why: a `tool_use` with
31
+ * no `tool_result` is an HTTP 400 on the next provider request.
32
+ */
33
+ import { type AgentTool, type ToolResult } from '@aragon-agent/core';
34
+ import type { HookReply } from './dispatch.js';
35
+ import type { HookTimeoutPolicy } from './limits.js';
36
+ /** Everything the wrapper needs from the hook runtime. */
37
+ export interface ToolHookRunner {
38
+ /** `true` when the thread has at least one hook on this point, so no wait is paid. */
39
+ hasTools(point: 'tool_pre' | 'tool_post', tool: string): boolean;
40
+ beforeTool(input: {
41
+ tool: AgentTool;
42
+ callId: string;
43
+ params: Record<string, unknown>;
44
+ summary: string;
45
+ turn?: number;
46
+ signal?: AbortSignal;
47
+ }): Promise<ToolHookBefore>;
48
+ afterTool(input: {
49
+ tool: AgentTool;
50
+ callId: string;
51
+ result: ToolResult;
52
+ signal?: AbortSignal;
53
+ }): Promise<ToolResult>;
54
+ }
55
+ export type ToolHookBefore = {
56
+ allow: true;
57
+ params: Record<string, unknown>;
58
+ } | {
59
+ allow: false;
60
+ message: string;
61
+ reason?: string;
62
+ };
63
+ /** What `tool_pre`'s messages say when a hook refuses or fails to answer. */
64
+ export declare function hookBlockedMessage(tool: string, reply: HookReply, policy: HookTimeoutPolicy): {
65
+ message: string;
66
+ reason?: string;
67
+ };
68
+ /** What `tool_post` says when the result must be withheld. */
69
+ export declare function hookWithheldMessage(tool: string, reply: HookReply): string;
70
+ /**
71
+ * `tool_pre`. Returns the tool unchanged when nothing is registered for it - the fast
72
+ * path that makes "no hooks means today's behaviour" true at the object level too.
73
+ */
74
+ export declare function withToolPreHooks(tool: AgentTool, runner: ToolHookRunner): AgentTool;
75
+ /**
76
+ * `tool_post`. The outermost wrapper: it runs after every other decision, so a refusal
77
+ * written by an inner layer is redacted exactly like a real result.
78
+ */
79
+ export declare function withToolPostHooks(tool: AgentTool, runner: ToolHookRunner): AgentTool;
80
+ /**
81
+ * Re-validate a `tool_pre` rewrite against the tool's OWN schema (I-H3).
82
+ *
83
+ * TWO FAILURE MODES, ONE ANSWER: an invalid rewrite is reported as `invalid` and treated
84
+ * by the policy table, never handed to `ToolExecutor`. Letting the executor reject it
85
+ * would surface as "the model called this tool wrong", and the model would then spend a
86
+ * turn repairing a mistake a caller's hook made.
87
+ */
88
+ export declare function revalidateRewrite(tool: AgentTool, input: Record<string, unknown>): Promise<{
89
+ ok: true;
90
+ params: Record<string, unknown>;
91
+ } | {
92
+ ok: false;
93
+ message: string;
94
+ }>;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `tool_pre` and `tool_post`: the two hook points that sit in the tool wrapper chain
3
+ * (headless-threads-and-lifecycle-hooks section 2.4.4 / 2.4.5 / 2.4.9).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE PLACEMENT IS THE SECURITY PROPERTY, so it is argued here rather than in a diagram
8
+ * that can drift:
9
+ *
10
+ * tool_post <- outermost: covers everything below, including denial text
11
+ * jobNotices
12
+ * planGate
13
+ * skillCeiling
14
+ * tool_pre <- inside the two policy gates, outside the approval gate
15
+ * approvalGate
16
+ * confirmation
17
+ * the tool itself
18
+ *
19
+ * `tool_post` IS OUTSIDE `<job_notices>` because a background job's notice tail carries
20
+ * the LAST FEW LINES OF ITS RAW OUTPUT (`proc/notices.ts`). A `bash background:true`
21
+ * that printed a key would otherwise smuggle it into the model's view past any redactor
22
+ * installed inside that layer - which is precisely the case the feature exists for.
23
+ *
24
+ * `tool_pre` IS OUTSIDE `approvalGate` so a human approves the REWRITTEN arguments. The
25
+ * opposite order makes "approved A, ran B" possible, which is a defect rather than a
26
+ * style choice. It is INSIDE planGate / skillCeiling, which refuse without I/O: paying a
27
+ * round trip to ask about a tool that policy already refuses is a worse trade, and the
28
+ * trade-off is recorded as I-H4b so it is not "fixed" by accident.
29
+ *
30
+ * VERDICTS ARE TOOL RESULTS, NEVER THROWS. `tool-gate.ts` records why: a `tool_use` with
31
+ * no `tool_result` is an HTTP 400 on the next provider request.
32
+ */
33
+ import { ToolParamValidator, errorResult, formatValidationErrors, } from '@aragon-agent/core';
34
+ /** What `tool_pre`'s messages say when a hook refuses or fails to answer. */
35
+ export function hookBlockedMessage(tool, reply, policy) {
36
+ if (reply.outcome === 'answered' && reply.answer?.action === 'deny') {
37
+ const reason = reply.answer.reason;
38
+ const message = reply.answer.message;
39
+ return {
40
+ message: message ??
41
+ `A caller hook denied this tool call (${tool}).` +
42
+ (reason ? ` Reason: ${reason}.` : '') +
43
+ ' Do not retry the same call; explain what you were trying to do or take a ' +
44
+ 'different approach.',
45
+ ...(reason ? { reason } : {}),
46
+ };
47
+ }
48
+ const code = reply.code ?? 'hook_error';
49
+ if (policy === 'allow') {
50
+ // RESERVED, AND UNREACHABLE FROM HERE: `allow` means the caller's wrapper falls
51
+ // through to the original parameters before this function is called. Kept so the
52
+ // three-way policy is exhaustive in one place.
53
+ return { message: `Hook ${code} (policy allow).` };
54
+ }
55
+ return {
56
+ message: `A caller hook did not allow this tool call (${code}), so ${tool} was not run. ` +
57
+ 'Do not retry the same call; explain what you needed it for instead.',
58
+ reason: code,
59
+ };
60
+ }
61
+ /** What `tool_post` says when the result must be withheld. */
62
+ export function hookWithheldMessage(tool, reply) {
63
+ if (reply.outcome === 'answered' && reply.answer?.action === 'deny') {
64
+ const message = reply.answer.message;
65
+ if (message)
66
+ return message;
67
+ }
68
+ return (`The caller withheld this tool result (${reply.code ?? 'hook_error'}). ` +
69
+ `The ${tool} call completed but its output is not available; take a different approach.`);
70
+ }
71
+ /**
72
+ * `tool_pre`. Returns the tool unchanged when nothing is registered for it - the fast
73
+ * path that makes "no hooks means today's behaviour" true at the object level too.
74
+ */
75
+ export function withToolPreHooks(tool, runner) {
76
+ const original = tool.execute;
77
+ return {
78
+ ...tool,
79
+ async execute(id, params, ctx) {
80
+ if (!runner.hasTools('tool_pre', tool.name))
81
+ return original(id, params, ctx);
82
+ const before = await runner.beforeTool({
83
+ tool,
84
+ callId: id,
85
+ params: (params ?? {}),
86
+ summary: summarize(tool.name, (params ?? {})),
87
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
88
+ });
89
+ if (!before.allow)
90
+ return errorResult(before.message);
91
+ // THE VALIDATED OBJECT, NOT THE ORIGINAL ONE. `ToolParamValidator` coerces and
92
+ // applies defaults, and the rewrite may legitimately have changed both.
93
+ return original(id, before.params, ctx);
94
+ },
95
+ };
96
+ }
97
+ /**
98
+ * `tool_post`. The outermost wrapper: it runs after every other decision, so a refusal
99
+ * written by an inner layer is redacted exactly like a real result.
100
+ */
101
+ export function withToolPostHooks(tool, runner) {
102
+ const original = tool.execute;
103
+ return {
104
+ ...tool,
105
+ async execute(id, params, ctx) {
106
+ const result = await original(id, params, ctx);
107
+ if (!runner.hasTools('tool_post', tool.name))
108
+ return result;
109
+ return runner.afterTool({
110
+ tool,
111
+ callId: id,
112
+ result,
113
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
114
+ });
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Re-validate a `tool_pre` rewrite against the tool's OWN schema (I-H3).
120
+ *
121
+ * TWO FAILURE MODES, ONE ANSWER: an invalid rewrite is reported as `invalid` and treated
122
+ * by the policy table, never handed to `ToolExecutor`. Letting the executor reject it
123
+ * would surface as "the model called this tool wrong", and the model would then spend a
124
+ * turn repairing a mistake a caller's hook made.
125
+ */
126
+ export async function revalidateRewrite(tool, input) {
127
+ const parameters = tool.parameters;
128
+ if (!parameters || typeof parameters !== 'object')
129
+ return { ok: true, params: input };
130
+ try {
131
+ const validated = await new ToolParamValidator().validate(parameters, input);
132
+ return { ok: true, params: validated };
133
+ }
134
+ catch (error) {
135
+ const errors = error.errors;
136
+ const detail = errors ? formatValidationErrors(errors) : error.message;
137
+ return { ok: false, message: clip(detail, 512) };
138
+ }
139
+ }
140
+ function clip(text, max) {
141
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
142
+ }
143
+ /** One readable line, mirroring `tools/tool-gate.ts`'s copy for the same three tools. */
144
+ function summarize(toolName, params) {
145
+ if (toolName === 'bash')
146
+ return `Run: ${String(params.command ?? '')}`;
147
+ if (toolName === 'write_file')
148
+ return `Write: ${String(params.path ?? '')}`;
149
+ if (toolName === 'edit_file')
150
+ return `Edit: ${String(params.path ?? '')}`;
151
+ if (toolName === 'skill_create')
152
+ return `Create skill: ${String(params.name ?? '')}`;
153
+ return toolName;
154
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `turn_start`: the one injection point that does not break the conversation
3
+ * (headless-threads-and-lifecycle-hooks section 2.4.6).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * INJECTION GOES THROUGH `queueUserMessage`, WHICH IS STEERING, AND THERE IS NO SECOND
8
+ * SAFE PLACE. Core drains the steering queue at exactly one checkpoint - the top of the
9
+ * loop, before the LLM call (`agent-loop.ts`). Draining after the LLM call orphans
10
+ * `tool_use` blocks and the next provider request is an HTTP 400; draining between two tool
11
+ * executions silently replaces the remaining calls with "skipped due to steering
12
+ * interrupt". Both are correct for a human pressing Enter and a defect generator for a
13
+ * programmatic host, which is why the only route this module has is the safe one.
14
+ *
15
+ * THE ORDER IS FIXED TOO, and it lives in the runner rather than here: the request budget
16
+ * is judged BEFORE the injection is queued. Injecting first and then being stopped by the
17
+ * budget would leave the text in the steering queue, where the NEXT request's loop top
18
+ * would push it into history - data quietly appearing in somebody else's request. The
19
+ * runner's `pendingInjection` is what makes that recoverable, and this file only produces
20
+ * the text.
21
+ */
22
+ import type { HookDispatcher, HookReply } from './dispatch.js';
23
+ import { type HookTimeoutPolicy } from './limits.js';
24
+ import type { HookRegistry } from './registry.js';
25
+ export type TurnSource = 'caller' | 'todo_continue' | 'result_repair';
26
+ export interface TurnStartInput {
27
+ turn: number;
28
+ source: TurnSource;
29
+ requestId?: string;
30
+ signal?: AbortSignal;
31
+ }
32
+ export interface TurnStartOutcome {
33
+ /** Text to inject, already joined and bounded. Absent means "nothing to say". */
34
+ injected?: string;
35
+ /**
36
+ * WHICH HOOK INJECTED, so a stranded injection can name itself.
37
+ *
38
+ * The runner reports `injection_stranded` when the text is queued but the turn never
39
+ * accepts it; without this field that report carries a placeholder id (`h?`) and the
40
+ * caller cannot tell which of its hooks to fix (I-H12).
41
+ */
42
+ hookId?: string;
43
+ /** `injection_stranded`, `hook_timeout`, `hook_invalid_answer`, ... (first reason wins). */
44
+ code?: string;
45
+ /** `--on-hook-timeout error`: this request must end as an error. */
46
+ isError?: boolean;
47
+ }
48
+ export interface TurnHook {
49
+ /** Never rejects: every failure is an outcome the runner records. */
50
+ beforeTurn(input: TurnStartInput): Promise<TurnStartOutcome>;
51
+ injections(): number;
52
+ reset(): void;
53
+ }
54
+ export interface TurnHookOptions {
55
+ registry: HookRegistry;
56
+ dispatch: HookDispatcher;
57
+ policy: () => HookTimeoutPolicy;
58
+ }
59
+ export declare function createTurnHook(options: TurnHookOptions): TurnHook;
60
+ /** Re-exported so `hooks/index.ts` can type its own helper without importing twice. */
61
+ export type { HookReply };
@@ -0,0 +1,59 @@
1
+ import { HOOK_LIMITS } from './limits.js';
2
+ export function createTurnHook(options) {
3
+ const { registry, dispatch } = options;
4
+ let injections = 0;
5
+ return {
6
+ async beforeTurn(input) {
7
+ const chain = registry.chainFor('turn_start');
8
+ if (chain.length === 0)
9
+ return {};
10
+ const parts = [];
11
+ let code;
12
+ let isError = false;
13
+ /** The first hook that actually injected: the injection's owner, for I-H12. */
14
+ let injectedBy;
15
+ for (const hook of chain) {
16
+ if (injections >= HOOK_LIMITS.maxInjectionsPerRequest) {
17
+ // A HOOK LOOP IS A PAID LOOP. The ceiling is per request, not per turn, so a
18
+ // hook that injects on every turn cannot drive an unbounded continuation.
19
+ code ??= 'hook_injection_limit';
20
+ break;
21
+ }
22
+ const reply = await dispatch.request({
23
+ hook,
24
+ // THE REQUEST'S OWN ID, ALWAYS KNOWN BY THIS POINT (`scope.begin()` has run and the
25
+ // runner passes it): a synthesized `turn_<n>` would collide across two requests in
26
+ // one thread, making the `hook_request` / `hook_resolved` pair impossible to
27
+ // correlate. The fallback survives only for a direct caller of this module in a
28
+ // test, where `turn` is a literal.
29
+ requestId: input.requestId ?? `turn_${input.turn}`,
30
+ payload: { turn: input.turn, source: input.source },
31
+ ...(input.signal ? { signal: input.signal } : {}),
32
+ });
33
+ if (reply.outcome === 'answered' && reply.answer?.action === 'inject') {
34
+ parts.push(reply.answer.text);
35
+ injections += 1;
36
+ continue;
37
+ }
38
+ if (reply.outcome === 'answered')
39
+ continue;
40
+ // A NON-ANSWER IS THE POLICY'S PROBLEM, and `deny` (the default) simply injects
41
+ // nothing: unlike a tool, there is nothing to refuse, so failing closed means
42
+ // "do not add context" rather than "stop the run".
43
+ code ??= reply.code ?? 'hook_timeout';
44
+ if (options.policy() === 'error')
45
+ isError = true;
46
+ }
47
+ return {
48
+ ...(parts.length > 0 ? { injected: parts.join('\n\n') } : {}),
49
+ ...(injectedBy !== undefined ? { hookId: injectedBy } : {}),
50
+ ...(code !== undefined ? { code } : {}),
51
+ ...(isError ? { isError: true } : {}),
52
+ };
53
+ },
54
+ injections: () => injections,
55
+ reset: () => {
56
+ injections = 0;
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Validating untrusted hook frames (headless-threads-and-lifecycle-hooks section 2.4.3).
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE, AND THAT IS THE POINT. `hook_register` and `hook_answer` arrive from a pipe, so
7
+ * every field is `unknown` until something says otherwise. This module is that
8
+ * something, and it is the ONLY reader (I-H7): a second validator somewhere else would
9
+ * mean an answer that is legal in one path and illegal in another, which is worse than
10
+ * having no validator at all.
11
+ *
12
+ * EVERY REFUSAL IS A STABLE CODE, NEVER A SILENT CORRECTION. The two exceptions are
13
+ * deliberate and both are documented where they happen: an out-of-range `timeoutMs` is
14
+ * clamped (with a warning on the ack) because the caller asked for a bounded round trip
15
+ * and got a smaller one, and a missing `tools` defaults to `['*']` because "every tool"
16
+ * is the only sensible reading of an absent filter.
17
+ */
18
+ import { type HookPoint } from './limits.js';
19
+ /** One hook, as the caller declared it, before the registry assigns an id. */
20
+ export interface ValidatedHookSpec {
21
+ point: HookPoint;
22
+ name?: string;
23
+ /** `['*']` when the caller named no tools. Only `tool_pre` / `tool_post` read it. */
24
+ tools: string[];
25
+ timeoutMs: number;
26
+ }
27
+ export type HookSpecValidation = {
28
+ ok: true;
29
+ specs: ValidatedHookSpec[];
30
+ warnings: string[];
31
+ } | {
32
+ ok: false;
33
+ code: string;
34
+ message: string;
35
+ };
36
+ /**
37
+ * Whole-frame validation: ALL specs are accepted or the frame is refused.
38
+ *
39
+ * PARTIAL SUCCESS IS UNIMPOSSIBLE TO DEBUG. A caller that sent six hooks and got
40
+ * `accepted:true` with two of them silently dropped has no way to tell which two, and
41
+ * the failure it causes later ("the redactor did not run for read_file") looks exactly
42
+ * like a bug in the caller's own matcher.
43
+ */
44
+ export declare function validateHookSpecs(raw: readonly unknown[], options: {
45
+ knownTools: ReadonlySet<string>;
46
+ }): HookSpecValidation;
47
+ /** The normalised answer, after the point has decided what is legal for it. */
48
+ export type HookAnswer = {
49
+ action: 'allow';
50
+ } | {
51
+ action: 'deny';
52
+ reason?: string;
53
+ message?: string;
54
+ } | {
55
+ action: 'rewrite';
56
+ text?: string;
57
+ input?: Record<string, unknown>;
58
+ } | {
59
+ action: 'inject';
60
+ text: string;
61
+ } | {
62
+ action: 'keep';
63
+ } | {
64
+ action: 'replace';
65
+ content: unknown;
66
+ isError?: boolean;
67
+ };
68
+ export type HookAnswerValidation = {
69
+ ok: true;
70
+ answer: HookAnswer;
71
+ } | {
72
+ ok: false;
73
+ code: string;
74
+ message: string;
75
+ };
76
+ /**
77
+ * THE FOUR POINTS' LEGAL ACTIONS, ENFORCED HERE AND NOWHERE ELSE.
78
+ *
79
+ * A single `validateHookAnswer(point, raw)` rather than four validators, because the
80
+ * frame is one shape and the point is a property of the request the CLI made - so the
81
+ * caller never has to restate which point it is answering.
82
+ */
83
+ export declare function validateHookAnswer(point: HookPoint, raw: unknown): HookAnswerValidation;
84
+ /** Bound the one caller-supplied string that becomes MODEL-VISIBLE (I-H2's sibling). */
85
+ export declare function clipReason(value: unknown): string | undefined;