skyloom 1.14.6 → 1.15.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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +74 -0
- package/CONVERSION_PLAN.md +191 -191
- package/README.md +523 -220
- package/config/default.yaml +46 -43
- package/config/models.yaml +928 -155
- package/config/providers.yaml +109 -6
- package/dist/agents/snow.d.ts +2 -0
- package/dist/agents/snow.d.ts.map +1 -1
- package/dist/agents/snow.js +36 -5
- package/dist/agents/snow.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +207 -1
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +190 -40
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +6 -31
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent.d.ts +6 -4
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +61 -20
- package/dist/core/agent.js.map +1 -1
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +30 -9
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/commands.d.ts +110 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +633 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/concurrency.d.ts +38 -0
- package/dist/core/concurrency.d.ts.map +1 -0
- package/dist/core/concurrency.js +65 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/factory.js +16 -16
- package/dist/core/file_checkpoint.d.ts +9 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -1
- package/dist/core/file_checkpoint.js +33 -1
- package/dist/core/file_checkpoint.js.map +1 -1
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +66 -13
- package/dist/core/llm.js.map +1 -1
- package/dist/core/memory.js +51 -51
- package/dist/core/schemas.d.ts +16 -0
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +32 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +27 -0
- package/dist/core/security.js.map +1 -1
- package/dist/core/skymd.js +14 -14
- package/dist/core/trace.d.ts +105 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +213 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/tools/builtin.d.ts +2 -6
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +180 -125
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/extra.d.ts +13 -0
- package/dist/tools/extra.d.ts.map +1 -0
- package/dist/tools/extra.js +827 -0
- package/dist/tools/extra.js.map +1 -0
- package/dist/tools/guards.d.ts +12 -0
- package/dist/tools/guards.d.ts.map +1 -0
- package/dist/tools/guards.js +143 -0
- package/dist/tools/guards.js.map +1 -0
- package/dist/tools/model_tool.d.ts.map +1 -1
- package/dist/tools/model_tool.js +24 -4
- package/dist/tools/model_tool.js.map +1 -1
- package/dist/web/markdown.d.ts +32 -0
- package/dist/web/markdown.d.ts.map +1 -0
- package/dist/web/markdown.js +202 -0
- package/dist/web/markdown.js.map +1 -0
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -582
- package/dist/web/server.js.map +1 -1
- package/dist/web/ui.d.ts +31 -0
- package/dist/web/ui.d.ts.map +1 -0
- package/dist/web/ui.js +1009 -0
- package/dist/web/ui.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -152
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +68 -68
- package/src/agents/snow.ts +38 -5
- package/src/cli/commands_md.ts +112 -112
- package/src/cli/input_macros.ts +83 -83
- package/src/cli/loom.ts +1041 -1041
- package/src/cli/loom_chat.ts +772 -603
- package/src/cli/main.ts +853 -723
- package/src/cli/tui.ts +264 -289
- package/src/core/agent/guard.ts +133 -133
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +1630 -1590
- package/src/core/agent_helpers.ts +500 -500
- package/src/core/bus.ts +221 -221
- package/src/core/cache.ts +153 -153
- package/src/core/catalog.ts +199 -178
- package/src/core/circuit_breaker.ts +119 -119
- package/src/core/commands.ts +704 -0
- package/src/core/concurrency.ts +73 -0
- package/src/core/config.ts +365 -365
- package/src/core/constants.ts +95 -95
- package/src/core/factory.ts +656 -656
- package/src/core/file_checkpoint.ts +163 -136
- package/src/core/hooks.ts +126 -126
- package/src/core/llm.ts +972 -915
- package/src/core/logger.ts +143 -143
- package/src/core/mcp.ts +1001 -1001
- package/src/core/memory.ts +1201 -1201
- package/src/core/middleware.ts +350 -350
- package/src/core/model_config.ts +159 -159
- package/src/core/pipelines.ts +424 -424
- package/src/core/schemas.ts +319 -282
- package/src/core/security.ts +27 -0
- package/src/core/semantic.ts +211 -211
- package/src/core/skill.ts +384 -384
- package/src/core/skymd.ts +143 -143
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +457 -457
- package/src/core/trace.ts +236 -0
- package/src/core/verify.ts +71 -71
- package/src/plugins/loader.ts +91 -91
- package/src/skills/loader.ts +75 -75
- package/src/tools/builtin.ts +571 -493
- package/src/tools/computer.ts +279 -279
- package/src/tools/extra.ts +662 -0
- package/src/tools/guards.ts +82 -0
- package/src/tools/model_tool.ts +93 -74
- package/src/tools/todo.ts +76 -76
- package/src/web/markdown.ts +193 -0
- package/src/web/server.ts +117 -693
- package/src/web/ui.ts +949 -0
- package/tests/agent.test.ts +211 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -124
- package/tests/claude_compat.test.ts +110 -110
- package/tests/commands.test.ts +103 -0
- package/tests/concurrency.test.ts +102 -0
- package/tests/config.test.ts +41 -41
- package/tests/extra_tools.test.ts +212 -0
- package/tests/fence_plugin.test.ts +52 -52
- package/tests/guard.test.ts +75 -75
- package/tests/loom.test.ts +337 -337
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -109
- package/tests/skymd.test.ts +146 -146
- package/tests/ssrf.test.ts +38 -38
- package/tests/structured_retry.test.ts +87 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -94
- package/tests/trace.test.ts +128 -0
- package/tests/tui.test.ts +67 -67
- package/tests/web.test.ts +169 -0
- package/tsconfig.json +38 -38
|
@@ -1,136 +1,163 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 文件级检查点 — snapshot files before agents mutate them; /rewind restores.
|
|
3
|
-
*
|
|
4
|
-
* Every chat turn / task opens a checkpoint "turn". Before write_file /
|
|
5
|
-
* edit_file / delete_file executes, the target's current content (or its
|
|
6
|
-
* absence) is snapshotted — first touch per path per turn wins, so a rewind
|
|
7
|
-
* restores the state from *before* the turn began. Lets users hand agents
|
|
8
|
-
* risky changes and undo them in one command, without involving git.
|
|
9
|
-
*
|
|
10
|
-
* Deliberately session-scoped and in-memory (like Claude Code checkpoints):
|
|
11
|
-
* not a git replacement, and `run_bash` side effects cannot be rewound.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import * as fs from 'fs';
|
|
15
|
-
import * as path from 'path';
|
|
16
|
-
import { getLogger } from './logger';
|
|
17
|
-
|
|
18
|
-
const log = getLogger('checkpoint');
|
|
19
|
-
|
|
20
|
-
interface FileSnapshot {
|
|
21
|
-
/** Absolute path. */
|
|
22
|
-
path: string;
|
|
23
|
-
/** Content before the turn, or null if the file did not exist. */
|
|
24
|
-
content: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface CheckpointTurn {
|
|
28
|
-
id: number;
|
|
29
|
-
label: string;
|
|
30
|
-
at: Date;
|
|
31
|
-
snapshots: Map<string, FileSnapshot>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const MAX_TURNS = 50;
|
|
35
|
-
const MAX_FILE_BYTES = 2 * 1024 * 1024; // skip snapshotting monsters
|
|
36
|
-
const MUTATING_TOOL_RE = /^(write_file|edit_file|delete_file)$/;
|
|
37
|
-
|
|
38
|
-
class FileCheckpointStore {
|
|
39
|
-
private turns: CheckpointTurn[] = [];
|
|
40
|
-
private current: CheckpointTurn | null = null;
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
fs.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 文件级检查点 — snapshot files before agents mutate them; /rewind restores.
|
|
3
|
+
*
|
|
4
|
+
* Every chat turn / task opens a checkpoint "turn". Before write_file /
|
|
5
|
+
* edit_file / delete_file executes, the target's current content (or its
|
|
6
|
+
* absence) is snapshotted — first touch per path per turn wins, so a rewind
|
|
7
|
+
* restores the state from *before* the turn began. Lets users hand agents
|
|
8
|
+
* risky changes and undo them in one command, without involving git.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately session-scoped and in-memory (like Claude Code checkpoints):
|
|
11
|
+
* not a git replacement, and `run_bash` side effects cannot be rewound.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { getLogger } from './logger';
|
|
17
|
+
|
|
18
|
+
const log = getLogger('checkpoint');
|
|
19
|
+
|
|
20
|
+
interface FileSnapshot {
|
|
21
|
+
/** Absolute path. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** Content before the turn, or null if the file did not exist. */
|
|
24
|
+
content: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CheckpointTurn {
|
|
28
|
+
id: number;
|
|
29
|
+
label: string;
|
|
30
|
+
at: Date;
|
|
31
|
+
snapshots: Map<string, FileSnapshot>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_TURNS = 50;
|
|
35
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024; // skip snapshotting monsters
|
|
36
|
+
const MUTATING_TOOL_RE = /^(write_file|edit_file|delete_file)$/;
|
|
37
|
+
|
|
38
|
+
class FileCheckpointStore {
|
|
39
|
+
private turns: CheckpointTurn[] = [];
|
|
40
|
+
private current: CheckpointTurn | null = null;
|
|
41
|
+
private redoStack: CheckpointTurn[] = [];
|
|
42
|
+
private seq = 0;
|
|
43
|
+
|
|
44
|
+
/** Open a new turn; subsequent snapshots attach to it. */
|
|
45
|
+
beginTurn(label: string): void {
|
|
46
|
+
// An empty previous turn is replaced, not stacked.
|
|
47
|
+
if (this.current && this.current.snapshots.size === 0) {
|
|
48
|
+
this.turns.pop();
|
|
49
|
+
}
|
|
50
|
+
this.current = { id: ++this.seq, label: label.slice(0, 60), at: new Date(), snapshots: new Map() };
|
|
51
|
+
this.turns.push(this.current);
|
|
52
|
+
if (this.turns.length > MAX_TURNS) this.turns.shift();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Snapshot a path before mutation (first touch per turn wins). */
|
|
56
|
+
snapshot(rawPath: string): void {
|
|
57
|
+
if (!this.current) this.beginTurn('(implicit)');
|
|
58
|
+
const abs = path.resolve(rawPath);
|
|
59
|
+
if (this.current!.snapshots.has(abs)) return;
|
|
60
|
+
let content: string | null = null;
|
|
61
|
+
try {
|
|
62
|
+
if (fs.existsSync(abs)) {
|
|
63
|
+
const stat = fs.statSync(abs);
|
|
64
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
|
|
65
|
+
content = fs.readFileSync(abs, 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
log.warn('snapshot_failed', { path: abs, error: String(e) });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.current!.snapshots.set(abs, { path: abs, content });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Should this tool call be snapshotted? Returns the path to snapshot. */
|
|
75
|
+
pathToSnapshot(toolName: string, args: Record<string, any>): string | null {
|
|
76
|
+
if (!MUTATING_TOOL_RE.test(toolName)) return null;
|
|
77
|
+
const p = args?.path;
|
|
78
|
+
return typeof p === 'string' && p.trim() ? p : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Turns that actually captured changes, newest first. */
|
|
82
|
+
list(): Array<{ id: number; label: string; at: Date; files: string[] }> {
|
|
83
|
+
return this.turns
|
|
84
|
+
.filter(t => t.snapshots.size > 0)
|
|
85
|
+
.map(t => ({ id: t.id, label: t.label, at: t.at, files: [...t.snapshots.keys()] }))
|
|
86
|
+
.reverse();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Restore the last `count` non-empty turns (newest backwards). When the
|
|
91
|
+
* same file appears in several turns, the oldest snapshot wins — that is
|
|
92
|
+
* the state from before the earliest rewound turn.
|
|
93
|
+
*/
|
|
94
|
+
rewind(count: number = 1): { restored: string[]; deleted: string[]; turns: number } {
|
|
95
|
+
const nonEmpty = this.turns.filter(t => t.snapshots.size > 0);
|
|
96
|
+
const target = nonEmpty.slice(-count);
|
|
97
|
+
if (target.length === 0) return { restored: [], deleted: [], turns: 0 };
|
|
98
|
+
|
|
99
|
+
// oldest-first iteration: later assignments overwrite, so the oldest
|
|
100
|
+
// snapshot per path ends up in the map
|
|
101
|
+
const finalState = new Map<string, FileSnapshot>();
|
|
102
|
+
for (let i = target.length - 1; i >= 0; i--) {
|
|
103
|
+
for (const snap of target[i].snapshots.values()) finalState.set(snap.path, snap);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const restored: string[] = [];
|
|
107
|
+
const deleted: string[] = [];
|
|
108
|
+
for (const snap of finalState.values()) {
|
|
109
|
+
try {
|
|
110
|
+
if (snap.content === null) {
|
|
111
|
+
if (fs.existsSync(snap.path)) { fs.unlinkSync(snap.path); deleted.push(snap.path); }
|
|
112
|
+
} else {
|
|
113
|
+
fs.mkdirSync(path.dirname(snap.path), { recursive: true });
|
|
114
|
+
fs.writeFileSync(snap.path, snap.content, 'utf-8');
|
|
115
|
+
restored.push(snap.path);
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
log.warn('rewind_failed', { path: snap.path, error: String(e) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Rewound turns are consumed and pushed to redo stack.
|
|
123
|
+
const ids = new Set(target.map(t => t.id));
|
|
124
|
+
const rewoundTurns = this.turns.filter(t => ids.has(t.id));
|
|
125
|
+
this.turns = this.turns.filter(t => !ids.has(t.id));
|
|
126
|
+
if (this.current && ids.has(this.current.id)) this.current = null;
|
|
127
|
+
this.redoStack.push(...rewoundTurns.reverse());
|
|
128
|
+
return { restored, deleted, turns: target.length };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Redo the last undone turns. Restores files to their post-rewind state.
|
|
133
|
+
*/
|
|
134
|
+
redo(): { restored: string[]; deleted: string[]; turns: number } {
|
|
135
|
+
if (this.redoStack.length === 0) return { restored: [], deleted: [], turns: 0 };
|
|
136
|
+
const target = [this.redoStack.pop()!];
|
|
137
|
+
const restored: string[] = [];
|
|
138
|
+
const deleted: string[] = [];
|
|
139
|
+
for (const snap of target[0].snapshots.values()) {
|
|
140
|
+
try {
|
|
141
|
+
if (snap.content === null) {
|
|
142
|
+
if (fs.existsSync(snap.path)) { fs.unlinkSync(snap.path); deleted.push(snap.path); }
|
|
143
|
+
} else {
|
|
144
|
+
fs.mkdirSync(path.dirname(snap.path), { recursive: true });
|
|
145
|
+
fs.writeFileSync(snap.path, snap.content, 'utf-8');
|
|
146
|
+
restored.push(snap.path);
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
log.warn('redo_failed', { path: snap.path, error: String(e) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { restored, deleted, turns: target.length };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Test/reset hook. */
|
|
156
|
+
clear(): void { this.turns = []; this.current = null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let _store: FileCheckpointStore | null = null;
|
|
160
|
+
export function getFileCheckpoints(): FileCheckpointStore {
|
|
161
|
+
if (!_store) _store = new FileCheckpointStore();
|
|
162
|
+
return _store;
|
|
163
|
+
}
|
package/src/core/hooks.ts
CHANGED
|
@@ -1,126 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hooks — user-configured shell commands at tool-execution lifecycle points.
|
|
3
|
-
*
|
|
4
|
-
* Unlike prompts or memory, hooks are *enforced*: they run regardless of what
|
|
5
|
-
* the model decides. A pre_tool hook exiting non-zero blocks the call.
|
|
6
|
-
*
|
|
7
|
-
* config.yaml:
|
|
8
|
-
* hooks:
|
|
9
|
-
* session_start:
|
|
10
|
-
* - "echo session up"
|
|
11
|
-
* pre_tool:
|
|
12
|
-
* - matcher: "run_bash|delete_file" # regex on tool name
|
|
13
|
-
* command: "./scripts/guard.sh" # non-zero exit blocks the tool
|
|
14
|
-
* post_tool:
|
|
15
|
-
* - matcher: "write_file|edit_file"
|
|
16
|
-
* command: "npx prettier --write \"$SKY_FILE\""
|
|
17
|
-
*
|
|
18
|
-
* Hook env: SKY_TOOL (tool name), SKY_ARGS (args JSON), SKY_FILE (path arg
|
|
19
|
-
* if present), SKY_AGENT (agent name).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { spawnSync } from 'child_process';
|
|
23
|
-
import { getLogger } from './logger';
|
|
24
|
-
|
|
25
|
-
const log = getLogger('hooks');
|
|
26
|
-
|
|
27
|
-
export interface HookSpec {
|
|
28
|
-
matcher?: string;
|
|
29
|
-
command: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface Hooks {
|
|
33
|
-
sessionStart: string[];
|
|
34
|
-
preTool: HookSpec[];
|
|
35
|
-
postTool: HookSpec[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const HOOK_TIMEOUT_MS = 30_000;
|
|
39
|
-
|
|
40
|
-
function normalizeSpecs(raw: any): HookSpec[] {
|
|
41
|
-
if (!Array.isArray(raw)) return [];
|
|
42
|
-
const out: HookSpec[] = [];
|
|
43
|
-
for (const item of raw) {
|
|
44
|
-
if (typeof item === 'string' && item.trim()) out.push({ command: item });
|
|
45
|
-
else if (item && typeof item.command === 'string' && item.command.trim()) {
|
|
46
|
-
out.push({ matcher: typeof item.matcher === 'string' ? item.matcher : undefined, command: item.command });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function loadHooks(config: any): Hooks {
|
|
53
|
-
const h: any = config?.hooks || {};
|
|
54
|
-
return {
|
|
55
|
-
sessionStart: normalizeSpecs(h.session_start).map(s => s.command),
|
|
56
|
-
preTool: normalizeSpecs(h.pre_tool),
|
|
57
|
-
postTool: normalizeSpecs(h.post_tool),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function matches(spec: HookSpec, toolName: string): boolean {
|
|
62
|
-
if (!spec.matcher) return true;
|
|
63
|
-
try {
|
|
64
|
-
return new RegExp(spec.matcher).test(toolName);
|
|
65
|
-
} catch {
|
|
66
|
-
return spec.matcher === toolName;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function hookEnv(toolName: string, args: Record<string, any>, agent: string): NodeJS.ProcessEnv {
|
|
71
|
-
return {
|
|
72
|
-
...process.env,
|
|
73
|
-
SKY_TOOL: toolName,
|
|
74
|
-
SKY_ARGS: JSON.stringify(args ?? {}).slice(0, 8000),
|
|
75
|
-
SKY_FILE: String(args?.path ?? args?.file ?? args?.file_path ?? ''),
|
|
76
|
-
SKY_AGENT: agent,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function runHook(command: string, env: NodeJS.ProcessEnv): { code: number; output: string } {
|
|
81
|
-
const r = spawnSync(command, { shell: true, encoding: 'utf-8', timeout: HOOK_TIMEOUT_MS, env });
|
|
82
|
-
const output = `${r.stdout || ''}${r.stderr || ''}`.trim().slice(0, 1000);
|
|
83
|
-
return { code: r.error ? 1 : (r.status ?? 1), output };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Run matching pre_tool hooks. The first non-zero exit blocks the call.
|
|
88
|
-
*/
|
|
89
|
-
export function runPreToolHooks(
|
|
90
|
-
hooks: Hooks,
|
|
91
|
-
toolName: string,
|
|
92
|
-
args: Record<string, any>,
|
|
93
|
-
agent: string
|
|
94
|
-
): { allowed: boolean; reason: string } {
|
|
95
|
-
for (const spec of hooks.preTool) {
|
|
96
|
-
if (!matches(spec, toolName)) continue;
|
|
97
|
-
const { code, output } = runHook(spec.command, hookEnv(toolName, args, agent));
|
|
98
|
-
if (code !== 0) {
|
|
99
|
-
log.warn('pre_tool_hook_blocked', { tool: toolName, hook: spec.command, code });
|
|
100
|
-
return { allowed: false, reason: output || `hook exited ${code}` };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return { allowed: true, reason: '' };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Run matching post_tool hooks (best-effort; failures only logged). */
|
|
107
|
-
export function runPostToolHooks(
|
|
108
|
-
hooks: Hooks,
|
|
109
|
-
toolName: string,
|
|
110
|
-
args: Record<string, any>,
|
|
111
|
-
agent: string
|
|
112
|
-
): void {
|
|
113
|
-
for (const spec of hooks.postTool) {
|
|
114
|
-
if (!matches(spec, toolName)) continue;
|
|
115
|
-
const { code, output } = runHook(spec.command, hookEnv(toolName, args, agent));
|
|
116
|
-
if (code !== 0) log.warn('post_tool_hook_failed', { tool: toolName, hook: spec.command, code, output });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Run session_start hooks once at system construction. */
|
|
121
|
-
export function runSessionStartHooks(hooks: Hooks): void {
|
|
122
|
-
for (const command of hooks.sessionStart) {
|
|
123
|
-
const { code, output } = runHook(command, process.env);
|
|
124
|
-
if (code !== 0) log.warn('session_start_hook_failed', { hook: command, code, output });
|
|
125
|
-
}
|
|
126
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Hooks — user-configured shell commands at tool-execution lifecycle points.
|
|
3
|
+
*
|
|
4
|
+
* Unlike prompts or memory, hooks are *enforced*: they run regardless of what
|
|
5
|
+
* the model decides. A pre_tool hook exiting non-zero blocks the call.
|
|
6
|
+
*
|
|
7
|
+
* config.yaml:
|
|
8
|
+
* hooks:
|
|
9
|
+
* session_start:
|
|
10
|
+
* - "echo session up"
|
|
11
|
+
* pre_tool:
|
|
12
|
+
* - matcher: "run_bash|delete_file" # regex on tool name
|
|
13
|
+
* command: "./scripts/guard.sh" # non-zero exit blocks the tool
|
|
14
|
+
* post_tool:
|
|
15
|
+
* - matcher: "write_file|edit_file"
|
|
16
|
+
* command: "npx prettier --write \"$SKY_FILE\""
|
|
17
|
+
*
|
|
18
|
+
* Hook env: SKY_TOOL (tool name), SKY_ARGS (args JSON), SKY_FILE (path arg
|
|
19
|
+
* if present), SKY_AGENT (agent name).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'child_process';
|
|
23
|
+
import { getLogger } from './logger';
|
|
24
|
+
|
|
25
|
+
const log = getLogger('hooks');
|
|
26
|
+
|
|
27
|
+
export interface HookSpec {
|
|
28
|
+
matcher?: string;
|
|
29
|
+
command: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Hooks {
|
|
33
|
+
sessionStart: string[];
|
|
34
|
+
preTool: HookSpec[];
|
|
35
|
+
postTool: HookSpec[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HOOK_TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
function normalizeSpecs(raw: any): HookSpec[] {
|
|
41
|
+
if (!Array.isArray(raw)) return [];
|
|
42
|
+
const out: HookSpec[] = [];
|
|
43
|
+
for (const item of raw) {
|
|
44
|
+
if (typeof item === 'string' && item.trim()) out.push({ command: item });
|
|
45
|
+
else if (item && typeof item.command === 'string' && item.command.trim()) {
|
|
46
|
+
out.push({ matcher: typeof item.matcher === 'string' ? item.matcher : undefined, command: item.command });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadHooks(config: any): Hooks {
|
|
53
|
+
const h: any = config?.hooks || {};
|
|
54
|
+
return {
|
|
55
|
+
sessionStart: normalizeSpecs(h.session_start).map(s => s.command),
|
|
56
|
+
preTool: normalizeSpecs(h.pre_tool),
|
|
57
|
+
postTool: normalizeSpecs(h.post_tool),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function matches(spec: HookSpec, toolName: string): boolean {
|
|
62
|
+
if (!spec.matcher) return true;
|
|
63
|
+
try {
|
|
64
|
+
return new RegExp(spec.matcher).test(toolName);
|
|
65
|
+
} catch {
|
|
66
|
+
return spec.matcher === toolName;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hookEnv(toolName: string, args: Record<string, any>, agent: string): NodeJS.ProcessEnv {
|
|
71
|
+
return {
|
|
72
|
+
...process.env,
|
|
73
|
+
SKY_TOOL: toolName,
|
|
74
|
+
SKY_ARGS: JSON.stringify(args ?? {}).slice(0, 8000),
|
|
75
|
+
SKY_FILE: String(args?.path ?? args?.file ?? args?.file_path ?? ''),
|
|
76
|
+
SKY_AGENT: agent,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runHook(command: string, env: NodeJS.ProcessEnv): { code: number; output: string } {
|
|
81
|
+
const r = spawnSync(command, { shell: true, encoding: 'utf-8', timeout: HOOK_TIMEOUT_MS, env });
|
|
82
|
+
const output = `${r.stdout || ''}${r.stderr || ''}`.trim().slice(0, 1000);
|
|
83
|
+
return { code: r.error ? 1 : (r.status ?? 1), output };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run matching pre_tool hooks. The first non-zero exit blocks the call.
|
|
88
|
+
*/
|
|
89
|
+
export function runPreToolHooks(
|
|
90
|
+
hooks: Hooks,
|
|
91
|
+
toolName: string,
|
|
92
|
+
args: Record<string, any>,
|
|
93
|
+
agent: string
|
|
94
|
+
): { allowed: boolean; reason: string } {
|
|
95
|
+
for (const spec of hooks.preTool) {
|
|
96
|
+
if (!matches(spec, toolName)) continue;
|
|
97
|
+
const { code, output } = runHook(spec.command, hookEnv(toolName, args, agent));
|
|
98
|
+
if (code !== 0) {
|
|
99
|
+
log.warn('pre_tool_hook_blocked', { tool: toolName, hook: spec.command, code });
|
|
100
|
+
return { allowed: false, reason: output || `hook exited ${code}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { allowed: true, reason: '' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Run matching post_tool hooks (best-effort; failures only logged). */
|
|
107
|
+
export function runPostToolHooks(
|
|
108
|
+
hooks: Hooks,
|
|
109
|
+
toolName: string,
|
|
110
|
+
args: Record<string, any>,
|
|
111
|
+
agent: string
|
|
112
|
+
): void {
|
|
113
|
+
for (const spec of hooks.postTool) {
|
|
114
|
+
if (!matches(spec, toolName)) continue;
|
|
115
|
+
const { code, output } = runHook(spec.command, hookEnv(toolName, args, agent));
|
|
116
|
+
if (code !== 0) log.warn('post_tool_hook_failed', { tool: toolName, hook: spec.command, code, output });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Run session_start hooks once at system construction. */
|
|
121
|
+
export function runSessionStartHooks(hooks: Hooks): void {
|
|
122
|
+
for (const command of hooks.sessionStart) {
|
|
123
|
+
const { code, output } = runHook(command, process.env);
|
|
124
|
+
if (code !== 0) log.warn('session_start_hook_failed', { hook: command, code, output });
|
|
125
|
+
}
|
|
126
|
+
}
|