@pi-unipi/unipi 0.1.3 → 0.1.4

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 (32) hide show
  1. package/package.json +2 -1
  2. package/packages/subagents/src/__tests__/config.test.ts +240 -0
  3. package/packages/subagents/src/__tests__/esc-propagation.test.ts +162 -0
  4. package/packages/subagents/src/__tests__/file-lock.test.ts +244 -0
  5. package/packages/subagents/src/__tests__/workflow-integration.test.ts +334 -0
  6. package/packages/subagents/src/agent-manager.ts +323 -0
  7. package/packages/subagents/src/agent-runner.ts +306 -0
  8. package/packages/subagents/src/config.ts +147 -0
  9. package/packages/subagents/src/custom-agents.ts +118 -0
  10. package/packages/subagents/src/file-lock.ts +102 -0
  11. package/packages/subagents/src/index.ts +429 -0
  12. package/packages/subagents/src/model-resolver.ts +79 -0
  13. package/packages/subagents/src/prompts.ts +39 -0
  14. package/packages/subagents/src/types.ts +86 -0
  15. package/packages/subagents/src/widget.ts +193 -0
  16. package/packages/web-api/src/cache.ts +240 -0
  17. package/packages/web-api/src/commands.ts +45 -0
  18. package/packages/web-api/src/index.ts +100 -0
  19. package/packages/web-api/src/providers/base.ts +108 -0
  20. package/packages/web-api/src/providers/duckduckgo.ts +115 -0
  21. package/packages/web-api/src/providers/firecrawl.ts +105 -0
  22. package/packages/web-api/src/providers/jina-reader.ts +89 -0
  23. package/packages/web-api/src/providers/jina-search.ts +88 -0
  24. package/packages/web-api/src/providers/llm-summarize.ts +71 -0
  25. package/packages/web-api/src/providers/perplexity.ts +191 -0
  26. package/packages/web-api/src/providers/registry.ts +128 -0
  27. package/packages/web-api/src/providers/serpapi.ts +86 -0
  28. package/packages/web-api/src/providers/tavily.ts +95 -0
  29. package/packages/web-api/src/settings.ts +263 -0
  30. package/packages/web-api/src/tools.ts +329 -0
  31. package/packages/web-api/src/tui/provider-selector.ts +71 -0
  32. package/packages/web-api/src/tui/settings-dialog.ts +177 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "packages/*/index.ts",
32
+ "packages/*/src/**/*.ts",
32
33
  "packages/*/extensions/**/*.ts",
33
34
  "packages/*/skills/**/*",
34
35
  "packages/*/README.md"
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Test: Config auto-generation and corruption recovery
3
+ *
4
+ * Verifies:
5
+ * - Missing config → auto-generated with defaults
6
+ * - Corrupted config → renamed to .json.bak, fresh generated
7
+ * - Workspace config overrides global config
8
+ * - Atomic writes prevent corruption
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ // Inline config implementation for testing
18
+ const DEFAULT_CONFIG = {
19
+ maxConcurrent: 4,
20
+ enabled: true,
21
+ types: {
22
+ explore: { enabled: true },
23
+ work: { enabled: true },
24
+ },
25
+ };
26
+
27
+ interface SubagentsConfig {
28
+ maxConcurrent: number;
29
+ enabled: boolean;
30
+ types: Record<string, { enabled?: boolean }>;
31
+ }
32
+
33
+ function loadConfigFromPath(filePath: string): SubagentsConfig | null {
34
+ if (!existsSync(filePath)) return null;
35
+ try {
36
+ const content = readFileSync(filePath, "utf-8");
37
+ const parsed = JSON.parse(content);
38
+ if (typeof parsed !== "object" || parsed === null) return null;
39
+ return parsed as SubagentsConfig;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function repairCorrupted(filePath: string): SubagentsConfig {
46
+ const backupPath = filePath + ".bak";
47
+ try {
48
+ renameSync(filePath, backupPath);
49
+ } catch {
50
+ // If rename fails, just overwrite
51
+ }
52
+ writeConfigAtomic(filePath, DEFAULT_CONFIG);
53
+ return DEFAULT_CONFIG;
54
+ }
55
+
56
+ function writeConfigAtomic(filePath: string, config: SubagentsConfig): void {
57
+ const tmpPath = filePath + ".tmp";
58
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
59
+ renameSync(tmpPath, filePath);
60
+ }
61
+
62
+ function initConfig(globalDir: string, workspaceDir: string): SubagentsConfig {
63
+ const globalPath = join(globalDir, "subagents.json");
64
+ const workspacePath = join(workspaceDir, "subagents.json");
65
+
66
+ // Load or create global config
67
+ let globalConfig = loadConfigFromPath(globalPath);
68
+ if (globalConfig === null) {
69
+ globalConfig = repairCorrupted(globalPath);
70
+ }
71
+
72
+ // Load workspace override if exists
73
+ const workspaceConfig = loadConfigFromPath(workspacePath);
74
+
75
+ if (workspaceConfig) {
76
+ // Merge: workspace overrides global on any field present
77
+ return {
78
+ ...globalConfig,
79
+ ...workspaceConfig,
80
+ types: {
81
+ ...globalConfig.types,
82
+ ...workspaceConfig.types,
83
+ },
84
+ };
85
+ }
86
+
87
+ return globalConfig;
88
+ }
89
+
90
+ describe("Config Management", () => {
91
+ let testDir: string;
92
+ let globalDir: string;
93
+ let workspaceDir: string;
94
+
95
+ beforeEach(() => {
96
+ // Create temp directories for testing
97
+ testDir = join(tmpdir(), `subagents-test-${Date.now()}`);
98
+ globalDir = join(testDir, "global");
99
+ workspaceDir = join(testDir, "workspace");
100
+ mkdirSync(globalDir, { recursive: true });
101
+ mkdirSync(workspaceDir, { recursive: true });
102
+ });
103
+
104
+ afterEach(() => {
105
+ // Cleanup
106
+ rmSync(testDir, { recursive: true, force: true });
107
+ });
108
+
109
+ describe("Missing config", () => {
110
+ it("should auto-generate with defaults when no config exists", () => {
111
+ const config = initConfig(globalDir, workspaceDir);
112
+
113
+ assert.deepEqual(config, DEFAULT_CONFIG);
114
+ assert.equal(existsSync(join(globalDir, "subagents.json")), true);
115
+
116
+ // Verify the generated file
117
+ const content = readFileSync(join(globalDir, "subagents.json"), "utf-8");
118
+ const parsed = JSON.parse(content);
119
+ assert.deepEqual(parsed, DEFAULT_CONFIG);
120
+ });
121
+
122
+ it("should create global config even if workspace exists", () => {
123
+ // Write workspace config only
124
+ const workspaceConfig = { maxConcurrent: 8, enabled: true, types: {} };
125
+ writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
126
+
127
+ const config = initConfig(globalDir, workspaceDir);
128
+
129
+ // Global should be created
130
+ assert.equal(existsSync(join(globalDir, "subagents.json")), true);
131
+ // Config should merge
132
+ assert.equal(config.maxConcurrent, 8); // workspace overrides
133
+ });
134
+ });
135
+
136
+ describe("Corrupted config", () => {
137
+ it("should backup corrupted config and generate fresh", () => {
138
+ const configPath = join(globalDir, "subagents.json");
139
+ const backupPath = configPath + ".bak";
140
+
141
+ // Write corrupted JSON
142
+ writeFileSync(configPath, "{ invalid json !!!");
143
+
144
+ const config = initConfig(globalDir, workspaceDir);
145
+
146
+ // Should have created backup
147
+ assert.equal(existsSync(backupPath), true, "Backup should exist");
148
+ // Backup should contain corrupted content
149
+ assert.equal(readFileSync(backupPath, "utf-8"), "{ invalid json !!!");
150
+ // New config should be defaults
151
+ assert.deepEqual(config, DEFAULT_CONFIG);
152
+ // New file should be valid JSON
153
+ const content = readFileSync(configPath, "utf-8");
154
+ assert.deepEqual(JSON.parse(content), DEFAULT_CONFIG);
155
+ });
156
+
157
+ it("should handle completely empty file", () => {
158
+ const configPath = join(globalDir, "subagents.json");
159
+ writeFileSync(configPath, "");
160
+
161
+ const config = initConfig(globalDir, workspaceDir);
162
+
163
+ assert.deepEqual(config, DEFAULT_CONFIG);
164
+ assert.equal(existsSync(configPath + ".bak"), true);
165
+ });
166
+
167
+ it("should handle non-object JSON", () => {
168
+ const configPath = join(globalDir, "subagents.json");
169
+ writeFileSync(configPath, '"just a string"');
170
+
171
+ const config = initConfig(globalDir, workspaceDir);
172
+
173
+ // JSON.parse succeeds but returns string, not object
174
+ // loadConfigFromPath checks typeof === "object"
175
+ assert.deepEqual(config, DEFAULT_CONFIG);
176
+ });
177
+ });
178
+
179
+ describe("Workspace override", () => {
180
+ it("should merge workspace config with global", () => {
181
+ const globalConfig = { maxConcurrent: 4, enabled: true, types: { explore: { enabled: true } } };
182
+ const workspaceConfig = { maxConcurrent: 8, types: { work: { enabled: false } } };
183
+
184
+ writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
185
+ writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
186
+
187
+ const config = initConfig(globalDir, workspaceDir);
188
+
189
+ assert.equal(config.maxConcurrent, 8); // workspace overrides
190
+ assert.equal(config.enabled, true); // global preserved
191
+ assert.deepEqual(config.types.explore, { enabled: true }); // global preserved
192
+ assert.deepEqual(config.types.work, { enabled: false }); // workspace added
193
+ });
194
+
195
+ it("should override specific fields only", () => {
196
+ const globalConfig = {
197
+ maxConcurrent: 4,
198
+ enabled: true,
199
+ types: { explore: { enabled: true }, work: { enabled: true } },
200
+ };
201
+ const workspaceConfig = { enabled: false };
202
+
203
+ writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
204
+ writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
205
+
206
+ const config = initConfig(globalDir, workspaceDir);
207
+
208
+ assert.equal(config.maxConcurrent, 4); // global preserved
209
+ assert.equal(config.enabled, false); // workspace overrides
210
+ assert.deepEqual(config.types, { explore: { enabled: true }, work: { enabled: true } }); // global preserved
211
+ });
212
+
213
+ it("should handle empty workspace config", () => {
214
+ const globalConfig = { maxConcurrent: 4, enabled: true, types: {} };
215
+ writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
216
+ writeFileSync(join(workspaceDir, "subagents.json"), "{}");
217
+
218
+ const config = initConfig(globalDir, workspaceDir);
219
+
220
+ assert.deepEqual(config, globalConfig);
221
+ });
222
+ });
223
+
224
+ describe("Atomic writes", () => {
225
+ it("should write config atomically", () => {
226
+ const configPath = join(globalDir, "subagents.json");
227
+ const config = { maxConcurrent: 8, enabled: false, types: {} };
228
+
229
+ writeConfigAtomic(configPath, config);
230
+
231
+ // Should have main file
232
+ assert.equal(existsSync(configPath), true);
233
+ // Should not have temp file
234
+ assert.equal(existsSync(configPath + ".tmp"), false);
235
+ // Content should be valid
236
+ const content = readFileSync(configPath, "utf-8");
237
+ assert.deepEqual(JSON.parse(content), config);
238
+ });
239
+ });
240
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Test: ESC propagation — all children abort on parent ESC
3
+ *
4
+ * Verifies:
5
+ * - forwardAbortSignal wires parent signal to child session
6
+ * - abortAll stops all running agents
7
+ * - All agents stop within reasonable time
8
+ */
9
+
10
+ import { describe, it, mock, beforeEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ // Mock AbortController to track abort calls
14
+ function createMockAbortController() {
15
+ let aborted = false;
16
+ const listeners: Array<() => void> = [];
17
+ return {
18
+ get signal() {
19
+ return {
20
+ aborted,
21
+ addEventListener: (_event: string, listener: () => void) => {
22
+ listeners.push(listener);
23
+ },
24
+ removeEventListener: (_event: string, listener: () => void) => {
25
+ const idx = listeners.indexOf(listener);
26
+ if (idx !== -1) listeners.splice(idx, 1);
27
+ },
28
+ };
29
+ },
30
+ abort() {
31
+ aborted = true;
32
+ for (const listener of listeners) listener();
33
+ },
34
+ get wasAborted() {
35
+ return aborted;
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("ESC Propagation", () => {
41
+ describe("forwardAbortSignal", () => {
42
+ it("should call session.abort() when signal fires", () => {
43
+ // Simulate the forwardAbortSignal logic from agent-runner.ts
44
+ const sessionAborted = { value: false };
45
+ const session = { abort: () => { sessionAborted.value = true; } };
46
+ const controller = createMockAbortController();
47
+
48
+ // Wire abort signal
49
+ const onAbort = () => session.abort();
50
+ controller.signal.addEventListener("abort", onAbort);
51
+
52
+ // Trigger abort
53
+ controller.abort();
54
+
55
+ assert.equal(sessionAborted.value, true, "Session should be aborted");
56
+ });
57
+
58
+ it("should not call session.abort() if signal not fired", () => {
59
+ const sessionAborted = { value: false };
60
+ const session = { abort: () => { sessionAborted.value = true; } };
61
+ const controller = createMockAbortController();
62
+
63
+ const onAbort = () => session.abort();
64
+ controller.signal.addEventListener("abort", onAbort);
65
+
66
+ // Don't abort
67
+ assert.equal(sessionAborted.value, false, "Session should not be aborted");
68
+ });
69
+
70
+ it("should cleanup listener when returned function called", () => {
71
+ const controller = createMockAbortController();
72
+ let callCount = 0;
73
+ const onAbort = () => { callCount++; };
74
+ controller.signal.addEventListener("abort", onAbort);
75
+
76
+ // Simulate cleanup
77
+ const cleanup = () => controller.signal.removeEventListener("abort", onAbort);
78
+ cleanup();
79
+
80
+ controller.abort();
81
+ assert.equal(callCount, 0, "Listener should not fire after cleanup");
82
+ });
83
+ });
84
+
85
+ describe("abortAll", () => {
86
+ it("should abort all running agents", () => {
87
+ const agents = new Map<string, { abortController: ReturnType<typeof createMockAbortController>; status: string }>();
88
+
89
+ // Create 3 mock agents
90
+ for (let i = 0; i < 3; i++) {
91
+ const controller = createMockAbortController();
92
+ agents.set(`agent-${i}`, {
93
+ abortController: controller,
94
+ status: "running",
95
+ });
96
+ }
97
+
98
+ // Simulate abortAll
99
+ let abortedCount = 0;
100
+ for (const [id, record] of agents) {
101
+ if (record.status === "running") {
102
+ record.abortController.abort();
103
+ record.status = "stopped";
104
+ abortedCount++;
105
+ }
106
+ }
107
+
108
+ assert.equal(abortedCount, 3, "Should abort all 3 agents");
109
+ for (const [_, record] of agents) {
110
+ assert.equal(record.status, "stopped", "All agents should be stopped");
111
+ assert.equal(record.abortController.wasAborted, true, "All controllers should be aborted");
112
+ }
113
+ });
114
+
115
+ it("should handle queued agents by removing from queue", () => {
116
+ const queue = [
117
+ { id: "queued-1", status: "queued" },
118
+ { id: "queued-2", status: "queued" },
119
+ ];
120
+ const agents = new Map<string, { status: string }>();
121
+
122
+ for (const item of queue) {
123
+ agents.set(item.id, { status: item.status });
124
+ }
125
+
126
+ // Simulate abortAll for queued
127
+ for (const item of queue) {
128
+ const record = agents.get(item.id);
129
+ if (record) {
130
+ record.status = "stopped";
131
+ }
132
+ }
133
+ queue.length = 0;
134
+
135
+ assert.equal(queue.length, 0, "Queue should be empty");
136
+ for (const [_, record] of agents) {
137
+ assert.equal(record.status, "stopped", "All queued agents should be stopped");
138
+ }
139
+ });
140
+ });
141
+
142
+ describe("ESC timing", () => {
143
+ it("should abort within reasonable time", async () => {
144
+ const controller = createMockAbortController();
145
+ let abortedAt: number | null = null;
146
+ const startedAt = Date.now();
147
+
148
+ const onAbort = () => { abortedAt = Date.now(); };
149
+ controller.signal.addEventListener("abort", onAbort);
150
+
151
+ // Simulate abort after small delay
152
+ setTimeout(() => controller.abort(), 10);
153
+
154
+ // Wait for abort
155
+ await new Promise(resolve => setTimeout(resolve, 50));
156
+
157
+ assert.notEqual(abortedAt, null, "Should have aborted");
158
+ const elapsed = abortedAt! - startedAt;
159
+ assert.ok(elapsed < 500, `Abort should happen within 500ms, took ${elapsed}ms`);
160
+ });
161
+ });
162
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Test: File locking — concurrent writes to same file queue correctly
3
+ *
4
+ * Verifies:
5
+ * - Per-file locking works correctly
6
+ * - Same file writes queue (second waits for first)
7
+ * - Different file writes proceed in parallel
8
+ * - Lock release unblocks waiting acquires
9
+ * - releaseAll releases all locks for an agent
10
+ */
11
+
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+
15
+ // Inline FileLock implementation for testing (avoids TS import issues)
16
+ interface FileLockEntry {
17
+ agentId: string;
18
+ filePath: string;
19
+ promise: Promise<void>;
20
+ release: () => void;
21
+ }
22
+
23
+ class FileLock {
24
+ private locks = new Map<string, FileLockEntry>();
25
+ private queues = new Map<string, Array<() => void>>();
26
+
27
+ async acquire(filePath: string, agentId: string): Promise<() => void> {
28
+ while (this.locks.has(filePath)) {
29
+ await new Promise<void>((resolve) => {
30
+ const queue = this.queues.get(filePath) ?? [];
31
+ queue.push(resolve);
32
+ this.queues.set(filePath, queue);
33
+ });
34
+ }
35
+
36
+ let releaseFn: () => void;
37
+ const promise = new Promise<void>((resolve) => {
38
+ releaseFn = () => {
39
+ this.locks.delete(filePath);
40
+ resolve();
41
+ const queue = this.queues.get(filePath);
42
+ if (queue && queue.length > 0) {
43
+ const next = queue.shift()!;
44
+ next();
45
+ }
46
+ };
47
+ });
48
+
49
+ const entry: FileLockEntry = {
50
+ agentId,
51
+ filePath,
52
+ promise,
53
+ release: releaseFn!,
54
+ };
55
+
56
+ this.locks.set(filePath, entry);
57
+ return releaseFn!;
58
+ }
59
+
60
+ isLocked(filePath: string): boolean {
61
+ return this.locks.has(filePath);
62
+ }
63
+
64
+ getHolder(filePath: string): string | undefined {
65
+ return this.locks.get(filePath)?.agentId;
66
+ }
67
+
68
+ get lockCount(): number {
69
+ return this.locks.size;
70
+ }
71
+
72
+ releaseAll(agentId: string): void {
73
+ for (const [filePath, entry] of this.locks) {
74
+ if (entry.agentId === agentId) {
75
+ entry.release();
76
+ }
77
+ }
78
+ }
79
+
80
+ clear(): void {
81
+ for (const entry of this.locks.values()) {
82
+ entry.release();
83
+ }
84
+ this.locks.clear();
85
+ this.queues.clear();
86
+ }
87
+ }
88
+
89
+ describe("FileLock", () => {
90
+ describe("Basic locking", () => {
91
+ it("should acquire lock on unlocked file", async () => {
92
+ const lock = new FileLock();
93
+ const release = await lock.acquire("/src/auth.ts", "agent-1");
94
+
95
+ assert.equal(lock.isLocked("/src/auth.ts"), true);
96
+ assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
97
+ assert.equal(lock.lockCount, 1);
98
+
99
+ release();
100
+ assert.equal(lock.isLocked("/src/auth.ts"), false);
101
+ });
102
+
103
+ it("should track multiple locks on different files", async () => {
104
+ const lock = new FileLock();
105
+ const release1 = await lock.acquire("/src/auth.ts", "agent-1");
106
+ const release2 = await lock.acquire("/src/login.ts", "agent-2");
107
+
108
+ assert.equal(lock.lockCount, 2);
109
+ assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
110
+ assert.equal(lock.getHolder("/src/login.ts"), "agent-2");
111
+
112
+ release1();
113
+ release2();
114
+ assert.equal(lock.lockCount, 0);
115
+ });
116
+ });
117
+
118
+ describe("Queuing behavior", () => {
119
+ it("should queue second acquire on same file", async () => {
120
+ const lock = new FileLock();
121
+ const events: string[] = [];
122
+
123
+ // First acquire
124
+ const release1 = await lock.acquire("/src/auth.ts", "agent-1");
125
+ events.push("agent-1-acquired");
126
+
127
+ // Second acquire (should queue)
128
+ const acquire2Promise = lock.acquire("/src/auth.ts", "agent-2").then((release) => {
129
+ events.push("agent-2-acquired");
130
+ return release;
131
+ });
132
+
133
+ // agent-2 should not have acquired yet
134
+ assert.deepEqual(events, ["agent-1-acquired"]);
135
+
136
+ // Release first lock
137
+ release1();
138
+ events.push("agent-1-released");
139
+
140
+ // Wait for agent-2
141
+ const release2 = await acquire2Promise;
142
+ assert.deepEqual(events, ["agent-1-acquired", "agent-1-released", "agent-2-acquired"]);
143
+
144
+ release2();
145
+ });
146
+
147
+ it("should queue multiple acquires on same file", async () => {
148
+ const lock = new FileLock();
149
+ const events: string[] = [];
150
+
151
+ const release1 = await lock.acquire("/src/auth.ts", "agent-1");
152
+ events.push("1-acquired");
153
+
154
+ const p2 = lock.acquire("/src/auth.ts", "agent-2").then(r => { events.push("2-acquired"); return r; });
155
+ const p3 = lock.acquire("/src/auth.ts", "agent-3").then(r => { events.push("3-acquired"); return r; });
156
+
157
+ release1();
158
+ const release2 = await p2;
159
+ assert.deepEqual(events, ["1-acquired", "2-acquired"]);
160
+
161
+ release2();
162
+ const release3 = await p3;
163
+ assert.deepEqual(events, ["1-acquired", "2-acquired", "3-acquired"]);
164
+
165
+ release3();
166
+ });
167
+ });
168
+
169
+ describe("Parallel different files", () => {
170
+ it("should allow parallel writes to different files", async () => {
171
+ const lock = new FileLock();
172
+ const events: string[] = [];
173
+
174
+ // Both should acquire immediately (different files)
175
+ const release1 = await lock.acquire("/src/auth.ts", "agent-1");
176
+ events.push("auth-acquired");
177
+
178
+ const release2 = await lock.acquire("/src/login.ts", "agent-2");
179
+ events.push("login-acquired");
180
+
181
+ assert.deepEqual(events, ["auth-acquired", "login-acquired"]);
182
+ assert.equal(lock.lockCount, 2);
183
+
184
+ release1();
185
+ release2();
186
+ });
187
+ });
188
+
189
+ describe("releaseAll", () => {
190
+ it("should release all locks for a specific agent", async () => {
191
+ const lock = new FileLock();
192
+
193
+ // agent-1 holds 3 files
194
+ await lock.acquire("/src/a.ts", "agent-1");
195
+ await lock.acquire("/src/b.ts", "agent-1");
196
+ await lock.acquire("/src/c.ts", "agent-1");
197
+
198
+ // agent-2 holds 1 file
199
+ await lock.acquire("/src/d.ts", "agent-2");
200
+
201
+ assert.equal(lock.lockCount, 4);
202
+
203
+ // Release all for agent-1
204
+ lock.releaseAll("agent-1");
205
+
206
+ assert.equal(lock.lockCount, 1);
207
+ assert.equal(lock.isLocked("/src/a.ts"), false);
208
+ assert.equal(lock.isLocked("/src/b.ts"), false);
209
+ assert.equal(lock.isLocked("/src/c.ts"), false);
210
+ assert.equal(lock.isLocked("/src/d.ts"), true);
211
+ });
212
+
213
+ it("should unblock queued acquires when releasing all", async () => {
214
+ const lock = new FileLock();
215
+ const events: string[] = [];
216
+
217
+ const release1 = await lock.acquire("/src/a.ts", "agent-1");
218
+ const p2 = lock.acquire("/src/a.ts", "agent-2").then(r => { events.push("agent-2-acquired"); return r; });
219
+
220
+ // Release all for agent-1
221
+ lock.releaseAll("agent-1");
222
+
223
+ const release2 = await p2;
224
+ assert.deepEqual(events, ["agent-2-acquired"]);
225
+
226
+ release2();
227
+ });
228
+ });
229
+
230
+ describe("clear", () => {
231
+ it("should release all locks and clear queues", async () => {
232
+ const lock = new FileLock();
233
+
234
+ await lock.acquire("/src/a.ts", "agent-1");
235
+ await lock.acquire("/src/b.ts", "agent-2");
236
+
237
+ lock.clear();
238
+
239
+ assert.equal(lock.lockCount, 0);
240
+ assert.equal(lock.isLocked("/src/a.ts"), false);
241
+ assert.equal(lock.isLocked("/src/b.ts"), false);
242
+ });
243
+ });
244
+ });