zames_pro 2.59.0 → 2.60.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.60.0]
11
+
12
+ ### Added
13
+
14
+ - PreToolUse / PostToolUse hooks (see `src/hooks.ts`): a project policy can
15
+ attach an external script to every tool call via `.zames/hooks.json`
16
+ (`{ "PreToolUse": [{matcher, command}], "PostToolUse": [...] }`). A
17
+ PreToolUse hook that exits non-zero BLOCKS the call (its output becomes the
18
+ tool result); a PostToolUse hook's stdout is appended to the result.
19
+ Hooks are best-effort: a missing/malformed config, a crash or a 10s timeout
20
+ never fails the agent loop.
21
+
10
22
  ## [2.59.0]
11
23
 
12
24
  ### Added
@@ -208,7 +220,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
208
220
  - Session banner warns when no saved DeepSeek session exists.
209
221
  - `--no-color` flag (explicit `NO_COLOR`).
210
222
 
211
- [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.59.0...HEAD
223
+ [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.60.0...HEAD
224
+ [2.60.0]: https://github.com/Viqto0r/zames_pro/compare/v2.59.0...v2.60.0
212
225
  [2.59.0]: https://github.com/Viqto0r/zames_pro/compare/v2.58.0...v2.59.0
213
226
  [2.58.0]: https://github.com/Viqto0r/zames_pro/compare/v2.57.1...v2.58.0
214
227
  [2.57.1]: https://github.com/Viqto0r/zames_pro/compare/v2.57.0...v2.57.1
@@ -5,7 +5,13 @@ import { parseXmlToolCalls } from './xml-toolcall.js';
5
5
  import { translate } from './i18n.js';
6
6
  import { substituteAttachmentMarkers } from './commands.js';
7
7
  import { normText } from './browser.js';
8
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, }) {
8
+ import { loadHooks, runPreToolUse, runPostToolUse, } from './hooks.js';
9
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, }) {
10
+ // Resolve the hook config ONCE per task: a read per tool call would be
11
+ // wasteful, and a mid-task edit of hooks.json is not something to chase.
12
+ // `undefined` means "read .zames/hooks.json"; an explicit null disables
13
+ // hooks entirely.
14
+ const hookConfig = hooks === undefined ? loadHooks(workdir) : (hooks ?? {});
9
15
  // UI callbacks must NEVER break the agent loop. A rendering error (a huge
10
16
  // tool result, a broken markdown frame, a closed terminal) used to throw
11
17
  // out of the loop right after a tool call — the session looked "stopped
@@ -705,6 +711,21 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
705
711
  syncToolAbort();
706
712
  safeToolCall(call.tool, call.args);
707
713
  transcript?.log('tool_call', { tool: call.tool, args: call.args });
714
+ // PreToolUse hooks run BEFORE the tool. A non-zero exit BLOCKS the call:
715
+ // its output becomes the tool result and the tool itself never runs.
716
+ // Best-effort by design — hooks must not be able to kill the loop.
717
+ const denial = await runPreToolUse(hookConfig, call.tool, call.args, workdir);
718
+ if (denial !== null) {
719
+ const blocked = `Blocked by PreToolUse hook: ${denial}`;
720
+ transcript?.log('hook_pre_deny', { tool: call.tool, reason: denial });
721
+ safeToolResult(blocked);
722
+ transcript?.log('tool_result', {
723
+ tool: call.tool,
724
+ result: blocked,
725
+ });
726
+ results.push({ tool: call.tool, result: blocked });
727
+ continue;
728
+ }
708
729
  let result;
709
730
  // While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
710
731
  // boolean set by stopGeneration(), so the only way to turn it into a
@@ -722,6 +743,17 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
722
743
  finally {
723
744
  clearInterval(poll);
724
745
  }
746
+ // PostToolUse hooks run AFTER the tool; their stdout is appended to the
747
+ // result (e.g. `prettier` output) before it is fed back to the model.
748
+ // Best-effort: a hook failure is ignored, the tool result still stands.
749
+ const post = await runPostToolUse(hookConfig, call.tool, call.args, String(result), workdir);
750
+ if (post) {
751
+ transcript?.log('hook_post_output', { tool: call.tool, output: post });
752
+ result = `${String(result)}
753
+
754
+ [PostToolUse hook]
755
+ ${post}`;
756
+ }
725
757
  safeToolResult(result);
726
758
  transcript?.log('tool_result', {
727
759
  tool: call.tool,
package/dist/hooks.js ADDED
@@ -0,0 +1,154 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { exec } from 'child_process';
4
+ // A hook is a helper, not a task: if it has not finished in ten seconds the
5
+ // tool call must not be held hostage by it.
6
+ const HOOK_TIMEOUT_MS = 10_000;
7
+ const HOOK_MAX_BUFFER = 1024 * 1024;
8
+ export function hooksConfigPath(workdir) {
9
+ return path.join(workdir, '.zames', 'hooks.json');
10
+ }
11
+ /**
12
+ * Reads `.zames/hooks.json`. A missing, unreadable or malformed file yields an
13
+ * empty config: a broken hooks file must not stop the agent from starting.
14
+ */
15
+ export function loadHooks(workdir) {
16
+ let raw;
17
+ try {
18
+ raw = fs.readFileSync(hooksConfigPath(workdir), 'utf-8');
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ let data;
24
+ try {
25
+ data = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ if (!data || typeof data !== 'object')
31
+ return {};
32
+ const obj = data;
33
+ return {
34
+ PreToolUse: normalizeEntries(obj['PreToolUse']),
35
+ PostToolUse: normalizeEntries(obj['PostToolUse']),
36
+ };
37
+ }
38
+ function normalizeEntries(value) {
39
+ if (!Array.isArray(value))
40
+ return undefined;
41
+ const out = [];
42
+ for (const item of value) {
43
+ if (!item || typeof item !== 'object')
44
+ continue;
45
+ const entry = item;
46
+ const command = entry['command'];
47
+ if (typeof command !== 'string' || command.trim() === '')
48
+ continue;
49
+ const matcher = entry['matcher'];
50
+ out.push({
51
+ command,
52
+ matcher: typeof matcher === 'string' ? matcher : undefined,
53
+ });
54
+ }
55
+ return out.length ? out : undefined;
56
+ }
57
+ /** True when the entry applies to the tool. A bad regex matches nothing. */
58
+ export function matchesHook(entry, tool) {
59
+ if (!entry.matcher)
60
+ return true;
61
+ try {
62
+ return new RegExp(entry.matcher).test(tool);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function runHook(entry, event, tool, args, workdir, result) {
69
+ return new Promise((resolve) => {
70
+ const payload = {
71
+ event,
72
+ tool,
73
+ args: args ?? {},
74
+ ...(result === undefined ? {} : { result }),
75
+ };
76
+ const env = {
77
+ ...process.env,
78
+ ZAMES_HOOK_EVENT: event,
79
+ ZAMES_TOOL_NAME: tool,
80
+ ZAMES_TOOL_ARGS: JSON.stringify(args ?? {}),
81
+ ...(result === undefined ? {} : { ZAMES_TOOL_RESULT: result }),
82
+ };
83
+ let child;
84
+ try {
85
+ child = exec(entry.command, {
86
+ cwd: workdir,
87
+ timeout: HOOK_TIMEOUT_MS,
88
+ maxBuffer: HOOK_MAX_BUFFER,
89
+ windowsHide: true,
90
+ env,
91
+ shell: process.platform === 'win32'
92
+ ? process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'
93
+ : '/bin/sh',
94
+ }, (err, stdout, stderr) => {
95
+ let code = 0;
96
+ if (err) {
97
+ const c = err.code;
98
+ code = typeof c === 'number' ? c : 1;
99
+ }
100
+ resolve({
101
+ code,
102
+ stdout: (stdout || '').toString(),
103
+ stderr: (stderr || '').toString(),
104
+ });
105
+ });
106
+ }
107
+ catch {
108
+ // Spawning the shell itself failed — treat it as a hook error, not a
109
+ // loop error.
110
+ resolve({ code: 1, stdout: '', stderr: 'hook could not be started' });
111
+ return;
112
+ }
113
+ // The call is also fed on stdin so a hook can parse the full JSON without
114
+ // depending on env size limits. A hook that ignores stdin is fine.
115
+ if (child.stdin) {
116
+ child.stdin.on('error', () => { });
117
+ child.stdin.end(JSON.stringify(payload) + String.fromCharCode(10));
118
+ }
119
+ });
120
+ }
121
+ /**
122
+ * Runs every matching PreToolUse hook in order. Returns a denial reason when a
123
+ * hook exits non-zero (its stderr/stdout becomes the tool result and the tool
124
+ * itself does NOT run), or null to allow the call.
125
+ */
126
+ export async function runPreToolUse(hooks, tool, args, workdir) {
127
+ for (const entry of hooks?.PreToolUse ?? []) {
128
+ if (!matchesHook(entry, tool))
129
+ continue;
130
+ const r = await runHook(entry, 'PreToolUse', tool, args, workdir);
131
+ if (r.code !== 0) {
132
+ const reason = (r.stderr || r.stdout).trim();
133
+ return reason || `hook exited with code ${r.code}`;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * Runs every matching PostToolUse hook and returns their stdout, ready to be
140
+ * appended to the tool result. Errors are ignored: the tool already ran, and
141
+ * its result must be reported.
142
+ */
143
+ export async function runPostToolUse(hooks, tool, args, result, workdir) {
144
+ const parts = [];
145
+ for (const entry of hooks?.PostToolUse ?? []) {
146
+ if (!matchesHook(entry, tool))
147
+ continue;
148
+ const r = await runHook(entry, 'PostToolUse', tool, args, workdir, result);
149
+ const out = r.stdout.trim();
150
+ if (out)
151
+ parts.push(out);
152
+ }
153
+ return parts.join(String.fromCharCode(10));
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.59.0",
3
+ "version": "2.60.0",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright: reads and edits files, runs commands, commits to git.",
5
5
  "type": "module",
6
6
  "bin": {