@intx/agent 0.1.2 → 0.3.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.
Files changed (60) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +80 -5
  3. package/dist/agent.d.ts +116 -0
  4. package/dist/agent.js +682 -0
  5. package/dist/canonicalize.d.ts +15 -0
  6. package/dist/canonicalize.js +160 -0
  7. package/dist/default-director.d.ts +24 -0
  8. package/dist/default-director.js +45 -0
  9. package/dist/definition.d.ts +139 -0
  10. package/dist/definition.js +40 -0
  11. package/dist/director-registry.d.ts +47 -0
  12. package/dist/director-registry.js +87 -0
  13. package/dist/director-types.d.ts +80 -0
  14. package/dist/director-types.js +13 -0
  15. package/dist/director.d.ts +70 -0
  16. package/dist/director.js +131 -0
  17. package/dist/env-validation.d.ts +59 -0
  18. package/dist/env-validation.js +180 -0
  19. package/dist/env.d.ts +160 -0
  20. package/dist/env.js +53 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +23 -0
  23. package/dist/internal-fixtures/mail.d.ts +39 -0
  24. package/dist/internal-fixtures/mail.js +86 -0
  25. package/dist/internal-fixtures/planner.d.ts +19 -0
  26. package/dist/internal-fixtures/planner.js +49 -0
  27. package/dist/lock.d.ts +16 -0
  28. package/dist/lock.js +47 -0
  29. package/dist/namespace.d.ts +12 -0
  30. package/dist/namespace.js +39 -0
  31. package/dist/send-queue.d.ts +25 -0
  32. package/dist/send-queue.js +147 -0
  33. package/dist/source.d.ts +43 -0
  34. package/dist/source.js +118 -0
  35. package/dist/stream.d.ts +16 -0
  36. package/dist/stream.js +115 -0
  37. package/dist/testing/audit-noop.d.ts +7 -0
  38. package/dist/testing/audit-noop.js +25 -0
  39. package/dist/testing/authorize-allow.d.ts +8 -0
  40. package/dist/testing/authorize-allow.js +19 -0
  41. package/dist/testing/index.d.ts +2 -0
  42. package/dist/testing/index.js +17 -0
  43. package/dist/tool.d.ts +238 -0
  44. package/dist/tool.js +244 -0
  45. package/package.json +26 -7
  46. package/src/agent.test.ts +0 -46
  47. package/src/agent.ts +0 -494
  48. package/src/index.ts +0 -38
  49. package/src/lock.test.ts +0 -93
  50. package/src/lock.ts +0 -57
  51. package/src/send-queue.test.ts +0 -207
  52. package/src/send-queue.ts +0 -200
  53. package/src/source.test.ts +0 -171
  54. package/src/source.ts +0 -93
  55. package/src/stream.test.ts +0 -167
  56. package/src/stream.ts +0 -142
  57. package/src/tool.test.ts +0 -217
  58. package/src/tool.ts +0 -148
  59. package/tsconfig.json +0 -4
  60. package/tsconfig.tsbuildinfo +0 -1
package/src/agent.ts DELETED
@@ -1,494 +0,0 @@
1
- // In-process agent runtime.
2
- //
3
- // `createAgent` resolves storage, builds the source registry and tool
4
- // dispatcher, wires `createReactorAssembly`, and exposes the public Agent
5
- // surface. The agent owns the active inference source object so
6
- // `setSource` can mutate it in place and the reactor's lazy read at the
7
- // start of each inference call picks up the new credentials.
8
- //
9
- // Composition:
10
- // - `send()` enqueues into a FIFO `SendQueue` capped at `sendQueueMax`.
11
- // Per-send `AbortSignal` removes queued items or rejects in-flight
12
- // callers while letting the reactor cycle finish in the background.
13
- // - `stream()` returns a bounded `StreamConsumer` iterator; consumers
14
- // buffer independently and noisy backpressure poisons only the
15
- // affected iterator.
16
- // - `close()` aborts the reactor, drains the send queue with
17
- // `AgentClosedError`, terminates every active stream iterator, waits
18
- // up to `closeTimeoutMs` for the reactor's shutdown sequence to
19
- // complete (audit flush, in-flight commits), and finally releases
20
- // the singleton-per-`contextDir` lock so another agent can open the
21
- // same directory.
22
- //
23
- // `setSource` covers the whole source: id/provider/baseURL/apiKey/model
24
- // plus the model-bound `defaults` and `capabilities`. Credentials and
25
- // model rotate together via the shared source object the reactor reads
26
- // lazily at the start of each inference call. The director never names
27
- // a model — `capabilities.infer(options?)` does not take one — so the
28
- // active source's model is the single source of truth and rotations
29
- // take effect on the next inference call without any wrapper.
30
-
31
- import {
32
- createDefaultDirector,
33
- createReactorAssembly,
34
- type AuthzExtensionOptions,
35
- type Dependencies,
36
- type ReactorEmittedEvent,
37
- } from "@intx/inference";
38
- import { createInboundMessage } from "@intx/mime";
39
- import { createIsogitStore } from "@intx/storage-isogit";
40
- import type {
41
- AssistantTurn,
42
- AuditStore,
43
- BlobReader,
44
- ContextCommit,
45
- ContextStore,
46
- ConversationTurn,
47
- InboundMessage,
48
- InferenceSource,
49
- ReactorDirector,
50
- } from "@intx/types/runtime";
51
-
52
- import { acquireContextDirLock, type ContextDirLock } from "./lock";
53
- import { createSourceRegistry, type SourceRegistry } from "./source";
54
- import { createSendQueue, type SendQueue } from "./send-queue";
55
- import { createStreamConsumer, type StreamConsumer } from "./stream";
56
- import { createToolRunner, type AgentTool, type AgentToolRunner } from "./tool";
57
-
58
- const DEFAULT_SEND_FROM = "user@local";
59
- const DEFAULT_SEND_TO = "agent@local";
60
- const DEFAULT_SEND_QUEUE_MAX = 16;
61
- const DEFAULT_STREAM_BUFFER_MAX = 1024;
62
- const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
63
-
64
- export type AgentConfig = {
65
- /**
66
- * The conversation/history store. Exactly one of `contextStore` or
67
- * `contextDir` must be supplied. When `contextStore` is given the caller
68
- * owns the store's lifetime; the singleton-per-directory lock is not
69
- * acquired.
70
- */
71
- contextStore?: ContextStore;
72
-
73
- /**
74
- * Path to a directory the agent will manage as an isogit-backed
75
- * `ContextStore & AuditStore`. The singleton-per-directory lock is
76
- * acquired when this form is used.
77
- */
78
- contextDir?: string;
79
-
80
- /** Pre-configured inference sources. Must be non-empty. */
81
- sources: InferenceSource[];
82
- /** Must match the `id` field of one of `sources`. */
83
- defaultSource: string;
84
-
85
- /** System prompt for the default director. */
86
- systemPrompt: string;
87
- /** Tools registered via `tool()` / `stringTool()`. */
88
- tools: AgentTool[];
89
- /** Director override. Defaults to `createDefaultDirector`. */
90
- director?: ReactorDirector;
91
-
92
- /** Audit store. When omitted with `contextDir`, the isogit store is used. */
93
- auditStore?: AuditStore;
94
- /** Authz extension hook. */
95
- authorize?: AuthzExtensionOptions["authorize"];
96
- /** Override the default 10k tool-result size cap. */
97
- sizeCapMaxChars?: number;
98
-
99
- /** Override the auto-generated session ID. */
100
- sessionId?: string;
101
-
102
- /**
103
- * Maximum number of pending sends (active + queued). Once reached,
104
- * additional `send()` calls throw `SendQueueFullError` synchronously.
105
- * Defaults to 16.
106
- */
107
- sendQueueMax?: number;
108
-
109
- /**
110
- * Maximum events any single `stream()` consumer may buffer. When a
111
- * consumer falls more than this many events behind the next read on
112
- * that consumer's iterator throws `StreamBackpressureError`; other
113
- * consumers are unaffected. Defaults to 1024.
114
- */
115
- streamBufferMax?: number;
116
-
117
- /**
118
- * Inference dependencies (notably `fetch`) for the reactor's underlying
119
- * `runInference` call. Production callers should leave this undefined —
120
- * the assembly falls back to `createDefaultDependencies()` which binds
121
- * `globalThis.fetch`. Pass `setupHarness().deps` from
122
- * `@intx/inference-testing` in tests to swap the fetch
123
- * implementation for a deterministic stub.
124
- */
125
- deps?: Dependencies;
126
-
127
- /**
128
- * Maximum milliseconds `close()` waits for the reactor's shutdown
129
- * sequence (which flushes audit and any pending commits) before
130
- * releasing the singleton `contextDir` lock and returning. Defaults
131
- * to 5000ms. Set to 0 to release immediately without waiting (useful
132
- * for tests where the reactor's shutdown is intentionally blocked).
133
- */
134
- closeTimeoutMs?: number;
135
- };
136
-
137
- export type SendOptions = {
138
- /**
139
- * Abort signal for this send. When the signal fires before processing
140
- * the call is dropped from the queue and the promise rejects with the
141
- * signal's reason. When it fires mid-cycle the promise rejects
142
- * immediately, but the underlying reactor cycle keeps running because
143
- * the reactor does not expose per-cycle cancellation — the next queued
144
- * send waits for that cycle to finish before starting. The reply (if
145
- * any) is still visible via `stream()` and `history()`.
146
- */
147
- signal?: AbortSignal;
148
- /** Override the default "from" header on the synthetic inbound message. */
149
- from?: string;
150
- };
151
-
152
- export type SendResult = {
153
- /** Reply text emitted by the director's `reply` action. */
154
- reply: string;
155
- /**
156
- * Full-fidelity assistant turn that produced the reply. Captured from
157
- * the reactor's `inference.done` event preceding `connector.reply`.
158
- */
159
- turn: ConversationTurn;
160
- };
161
-
162
- export type Agent = {
163
- send(
164
- content: string | InboundMessage,
165
- opts?: SendOptions,
166
- ): Promise<SendResult>;
167
- stream(): AsyncIterable<ReactorEmittedEvent>;
168
- deliver(message: InboundMessage): void;
169
- close(): Promise<void>;
170
- /**
171
- * Replace the active source's fields in place. Picked up at the start
172
- * of the next inference call. `model` rotates alongside the
173
- * credentials — the director does not name a model, so the active
174
- * source's `model` is what the next inference call uses without any
175
- * additional plumbing.
176
- */
177
- setSource(source: InferenceSource): void;
178
- /**
179
- * Project conversation history from the underlying context store.
180
- * Remains callable after `close()` — reads do not need the reactor
181
- * and the store is not destroyed by close. Returns the full-fidelity
182
- * `ConversationTurn[]` from the store's latest committed state.
183
- */
184
- history(): Promise<ConversationTurn[]>;
185
- /**
186
- * List recent checkpoints from the context store. Remains callable
187
- * after `close()` for the same reason as `history()`.
188
- */
189
- checkpoints(limit?: number): Promise<ContextCommit[]>;
190
- /**
191
- * Read the conversation turns recorded at a specific commit hash.
192
- * Remains callable after `close()` for the same reason as
193
- * `history()`.
194
- */
195
- readAt(hash: string): Promise<ConversationTurn[]>;
196
- readonly blobReader: BlobReader;
197
- };
198
-
199
- export class AgentConfigError extends Error {
200
- constructor(message: string) {
201
- super(message);
202
- this.name = "AgentConfigError";
203
- }
204
- }
205
-
206
- export class AgentClosedError extends Error {
207
- constructor() {
208
- super("agent is closed");
209
- this.name = "AgentClosedError";
210
- }
211
- }
212
-
213
- export async function createAgent(config: AgentConfig): Promise<Agent> {
214
- const hasStore = config.contextStore !== undefined;
215
- const hasDir = config.contextDir !== undefined;
216
- if (hasStore === hasDir) {
217
- throw new AgentConfigError(
218
- "exactly one of contextStore or contextDir is required",
219
- );
220
- }
221
-
222
- let contextStore: ContextStore;
223
- let auditStore: AuditStore | undefined;
224
- let lock: ContextDirLock | undefined;
225
-
226
- if (config.contextDir !== undefined) {
227
- lock = acquireContextDirLock(config.contextDir);
228
- try {
229
- const store = await createIsogitStore(config.contextDir);
230
- contextStore = store;
231
- auditStore = config.auditStore ?? store;
232
- } catch (cause) {
233
- lock.release();
234
- throw cause;
235
- }
236
- } else if (config.contextStore !== undefined) {
237
- contextStore = config.contextStore;
238
- auditStore = config.auditStore;
239
- } else {
240
- throw new AgentConfigError("unreachable: storage form validated above");
241
- }
242
-
243
- let sourceRegistry: SourceRegistry;
244
- let toolRunner: AgentToolRunner;
245
- try {
246
- sourceRegistry = createSourceRegistry({
247
- sources: config.sources,
248
- defaultSource: config.defaultSource,
249
- });
250
- toolRunner = createToolRunner(config.tools);
251
- } catch (cause) {
252
- if (lock !== undefined) lock.release();
253
- throw cause;
254
- }
255
-
256
- let director: ReactorDirector;
257
- if (config.director !== undefined) {
258
- director = config.director;
259
- } else {
260
- director = createDefaultDirector(
261
- config.systemPrompt,
262
- [...toolRunner.definitions],
263
- {},
264
- );
265
- }
266
-
267
- const sessionId = config.sessionId ?? crypto.randomUUID();
268
-
269
- const streamBufferMax = config.streamBufferMax ?? DEFAULT_STREAM_BUFFER_MAX;
270
- const streamConsumers = new Set<StreamConsumer>();
271
-
272
- // Per-active-cycle bookkeeping for send(). The reactor produces one or
273
- // more inference.done events during a cycle; we keep the most recent
274
- // assistant turn so the final connector.reply can be paired with the
275
- // full-fidelity turn (rather than a synthesized text-only fallback).
276
- type ActiveCycle = { lastAssistantTurn: AssistantTurn | undefined };
277
- let activeCycle: ActiveCycle | null = null;
278
-
279
- // sendQueue is built after the reactor (since its `start` callback
280
- // delivers into the reactor), but handleEvent — which is wired into the
281
- // reactor's assembly — needs to see sendQueue. Assigned exactly once
282
- // after the reactor exists and before reactor.start(); no event can
283
- // reach handleEvent before the queue is wired.
284
- // eslint-disable-next-line prefer-const -- forward declaration; const cannot express this ordering
285
- let sendQueue: SendQueue<InboundMessage, SendResult>;
286
-
287
- // shutdownComplete resolves from the assembly's onShutdown hook
288
- // (composed after audit flush by the assembly) or, as a fallback, from
289
- // handleEvent observing the reactor's terminal `reactor.done` event.
290
- // close() awaits this (with a timeout) before releasing the
291
- // contextDir lock so a subsequent createAgent on the same directory
292
- // sees a quiesced store.
293
- let resolveShutdown: () => void = () => {
294
- // Reassigned by the Promise constructor below; seed a no-op so the
295
- // forward-referenced call in handleEvent is safe even if the
296
- // Promise constructor has not yet run (it does, synchronously, on
297
- // the next line).
298
- };
299
- const shutdownComplete = new Promise<void>((resolve) => {
300
- resolveShutdown = resolve;
301
- });
302
-
303
- function buildSyntheticTurn(text: string): ConversationTurn {
304
- return {
305
- role: "assistant",
306
- content: [{ type: "text", text }],
307
- model: sourceRegistry.active.model,
308
- timestamp: Date.now(),
309
- };
310
- }
311
-
312
- function handleEvent(event: ReactorEmittedEvent): void {
313
- if (activeCycle !== null && event.type === "inference.done") {
314
- activeCycle.lastAssistantTurn = event.data.turn;
315
- }
316
-
317
- if (activeCycle !== null) {
318
- if (event.type === "connector.reply") {
319
- const turn: ConversationTurn =
320
- activeCycle.lastAssistantTurn ??
321
- buildSyntheticTurn(event.data.content);
322
- activeCycle = null;
323
- sendQueue.resolveActive({ reply: event.data.content, turn });
324
- } else if (event.type === "reactor.error" && event.data.fatal) {
325
- // Only fatal reactor errors terminate the active send. Non-fatal
326
- // errors (e.g. transient write/commit failures the reactor is
327
- // recovering from) are surfaced via stream() but must not
328
- // resolve send() — the cycle is still running and may yet
329
- // produce connector.reply or a fatal error.
330
- activeCycle = null;
331
- sendQueue.rejectActive(new Error(`reactor error: ${event.data.error}`));
332
- } else if (event.type === "reactor.done") {
333
- activeCycle = null;
334
- sendQueue.rejectActive(new AgentClosedError());
335
- }
336
- }
337
-
338
- // reactor.done is the reactor's terminal event. Resolve
339
- // shutdownComplete here in addition to the onShutdown hook so close()
340
- // does not hang for the full closeTimeoutMs on paths where the hook
341
- // never fires (e.g. the reactor's context-store load fails during
342
- // start, or the composed onShutdown wrapper throws during audit
343
- // flush). resolveShutdown is idempotent.
344
- if (event.type === "reactor.done") {
345
- resolveShutdown();
346
- }
347
-
348
- // Iterate a snapshot so removing closed consumers mid-iteration is
349
- // not just relying on Set's iteration tolerance.
350
- for (const consumer of Array.from(streamConsumers)) {
351
- consumer.push(event);
352
- if (consumer.closed) {
353
- streamConsumers.delete(consumer);
354
- }
355
- }
356
- }
357
-
358
- const { reactor, blobReader } = createReactorAssembly({
359
- sessionId,
360
- director,
361
- source: sourceRegistry.active,
362
- toolRunner,
363
- contextStore,
364
- onEvent: handleEvent,
365
- onShutdown: async () => {
366
- resolveShutdown();
367
- },
368
- ...(auditStore !== undefined ? { auditStore } : {}),
369
- ...(config.authorize !== undefined ? { authorize: config.authorize } : {}),
370
- ...(config.sizeCapMaxChars !== undefined
371
- ? { sizeCapMaxChars: config.sizeCapMaxChars }
372
- : {}),
373
- ...(config.deps !== undefined ? { deps: config.deps } : {}),
374
- });
375
-
376
- sendQueue = createSendQueue<InboundMessage, SendResult>({
377
- maxDepth: config.sendQueueMax ?? DEFAULT_SEND_QUEUE_MAX,
378
- start: (message) => {
379
- activeCycle = { lastAssistantTurn: undefined };
380
- reactor.deliver(message);
381
- },
382
- });
383
-
384
- reactor.start();
385
-
386
- let closed = false;
387
-
388
- function ensureOpen(): void {
389
- if (closed) throw new AgentClosedError();
390
- }
391
-
392
- function buildInboundMessage(
393
- content: string | InboundMessage,
394
- opts?: SendOptions,
395
- ): InboundMessage {
396
- if (typeof content !== "string") return content;
397
- // Conversation messages use `content` (a string); the mail-builder
398
- // rejects passing `payload` for conversation types.
399
- return createInboundMessage({
400
- from: opts?.from ?? DEFAULT_SEND_FROM,
401
- to: DEFAULT_SEND_TO,
402
- content,
403
- interchangeType: "conversation.message",
404
- });
405
- }
406
-
407
- function send(
408
- content: string | InboundMessage,
409
- opts?: SendOptions,
410
- ): Promise<SendResult> {
411
- // Closed-agent errors come back as rejections so callers can handle
412
- // them with `.catch()` instead of having to defensively wrap every
413
- // `agent.send(...)` in a try/catch. `SendQueueFullError` from
414
- // `sendQueue.enqueue` is left as a synchronous throw — it signals a
415
- // programmer error (the caller exceeded the configured queue cap)
416
- // and per the design must fail loud.
417
- if (closed) return Promise.reject(new AgentClosedError());
418
- const message = buildInboundMessage(content, opts);
419
- return sendQueue.enqueue(message, opts?.signal);
420
- }
421
-
422
- function stream(): AsyncIterable<ReactorEmittedEvent> {
423
- ensureOpen();
424
- const consumer = createStreamConsumer(streamBufferMax);
425
- streamConsumers.add(consumer);
426
- return consumer.iterator();
427
- }
428
-
429
- function deliver(message: InboundMessage): void {
430
- ensureOpen();
431
- reactor.deliver(message);
432
- }
433
-
434
- function setSource(source: InferenceSource): void {
435
- ensureOpen();
436
- sourceRegistry.setSource(source);
437
- }
438
-
439
- async function history(): Promise<ConversationTurn[]> {
440
- const loaded = await contextStore.load();
441
- return loaded.turns;
442
- }
443
-
444
- async function checkpoints(limit?: number): Promise<ContextCommit[]> {
445
- return contextStore.log(limit);
446
- }
447
-
448
- async function readAt(hash: string): Promise<ConversationTurn[]> {
449
- return contextStore.readAt(hash);
450
- }
451
-
452
- async function close(): Promise<void> {
453
- if (closed) return;
454
- closed = true;
455
- reactor.abort("user_disconnect");
456
- sendQueue.drain(new AgentClosedError());
457
- activeCycle = null;
458
- for (const consumer of streamConsumers) consumer.close();
459
- streamConsumers.clear();
460
-
461
- // Wait for the reactor's shutdown sequence (audit flush, in-flight
462
- // commits) before releasing the lock so a subsequent createAgent on
463
- // the same contextDir does not race with background writers against
464
- // the same .git directory. The timeout is a backstop: if the
465
- // reactor's shutdown is genuinely stuck (e.g. a parked test fetch
466
- // that never resolves) we release the lock anyway rather than
467
- // deadlock the caller. `closeTimeoutMs: 0` disables the wait.
468
- const timeoutMs = config.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
469
- if (timeoutMs > 0) {
470
- let timer: ReturnType<typeof setTimeout> | undefined;
471
- const timeout = new Promise<void>((resolve) => {
472
- timer = setTimeout(resolve, timeoutMs);
473
- });
474
- try {
475
- await Promise.race([shutdownComplete, timeout]);
476
- } finally {
477
- if (timer !== undefined) clearTimeout(timer);
478
- }
479
- }
480
- if (lock !== undefined) lock.release();
481
- }
482
-
483
- return {
484
- send,
485
- stream,
486
- deliver,
487
- close,
488
- setSource,
489
- history,
490
- checkpoints,
491
- readAt,
492
- blobReader,
493
- };
494
- }
package/src/index.ts DELETED
@@ -1,38 +0,0 @@
1
- // @intx/agent — in-process agent runtime.
2
- //
3
- // Sits on top of `createReactorAssembly` from `@intx/inference` to
4
- // provide a code-driven agent surface: send a message, stream events,
5
- // project history, hot-swap inference sources. Peer to `@intx/harness`;
6
- // the harness drives the reactor from a mail transport (INBOX watch,
7
- // connector threads, outbound replies via MessageTransport) while the
8
- // agent drives it from in-process calls.
9
-
10
- export { AgentInUseError } from "./lock";
11
- export {
12
- type AgentTool,
13
- type AgentToolRunner,
14
- type StringToolHandler,
15
- type ToolHandler,
16
- DuplicateToolError,
17
- createToolRunner,
18
- fromToolRunner,
19
- stringTool,
20
- tool,
21
- } from "./tool";
22
- export {
23
- type SourceRegistry,
24
- InvalidInferenceSourceError,
25
- SourceNotFoundError,
26
- createSourceRegistry,
27
- } from "./source";
28
- export {
29
- type Agent,
30
- type AgentConfig,
31
- type SendOptions,
32
- type SendResult,
33
- AgentClosedError,
34
- AgentConfigError,
35
- createAgent,
36
- } from "./agent";
37
- export { SendQueueFullError } from "./send-queue";
38
- export { StreamBackpressureError } from "./stream";
package/src/lock.test.ts DELETED
@@ -1,93 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { resolve } from "node:path";
3
-
4
- import { acquireContextDirLock, AgentInUseError } from "./lock";
5
-
6
- describe("acquireContextDirLock", () => {
7
- test("returns a lock whose path is the resolved absolute path", () => {
8
- const lock = acquireContextDirLock("/tmp/agent-lock-1");
9
- try {
10
- expect(lock.path).toBe(resolve("/tmp/agent-lock-1"));
11
- } finally {
12
- lock.release();
13
- }
14
- });
15
-
16
- test("rejects a second acquisition of the same directory", () => {
17
- const lock = acquireContextDirLock("/tmp/agent-lock-2");
18
- try {
19
- expect(() => acquireContextDirLock("/tmp/agent-lock-2")).toThrow(
20
- AgentInUseError,
21
- );
22
- } finally {
23
- lock.release();
24
- }
25
- });
26
-
27
- test("re-acquires the same directory after release", () => {
28
- const lock1 = acquireContextDirLock("/tmp/agent-lock-3");
29
- lock1.release();
30
- const lock2 = acquireContextDirLock("/tmp/agent-lock-3");
31
- try {
32
- expect(lock2.path).toBe(resolve("/tmp/agent-lock-3"));
33
- } finally {
34
- lock2.release();
35
- }
36
- });
37
-
38
- test("collides on lexically distinct but equivalent paths", () => {
39
- const lock = acquireContextDirLock("/tmp/agent-lock-4/../agent-lock-4/foo");
40
- try {
41
- expect(() => acquireContextDirLock("/tmp/agent-lock-4/foo")).toThrow(
42
- AgentInUseError,
43
- );
44
- } finally {
45
- lock.release();
46
- }
47
- });
48
-
49
- test("collides on a relative path that resolves to the same absolute path", () => {
50
- const absolute = resolve("relative-lock-test-dir");
51
- const lock = acquireContextDirLock("relative-lock-test-dir");
52
- try {
53
- expect(lock.path).toBe(absolute);
54
- expect(() => acquireContextDirLock(absolute)).toThrow(AgentInUseError);
55
- } finally {
56
- lock.release();
57
- }
58
- });
59
-
60
- test("does not collide between distinct directories", () => {
61
- const lock1 = acquireContextDirLock("/tmp/agent-lock-5a");
62
- const lock2 = acquireContextDirLock("/tmp/agent-lock-5b");
63
- try {
64
- expect(lock1.path).not.toBe(lock2.path);
65
- } finally {
66
- lock1.release();
67
- lock2.release();
68
- }
69
- });
70
-
71
- test("release is idempotent", () => {
72
- const lock = acquireContextDirLock("/tmp/agent-lock-6");
73
- lock.release();
74
- lock.release();
75
- const reacquired = acquireContextDirLock("/tmp/agent-lock-6");
76
- reacquired.release();
77
- });
78
-
79
- test("AgentInUseError exposes contextDir as the resolved path", () => {
80
- const lock = acquireContextDirLock("/tmp/agent-lock-7");
81
- try {
82
- acquireContextDirLock("/tmp/agent-lock-7");
83
- throw new Error("should have thrown AgentInUseError");
84
- } catch (err) {
85
- expect(err).toBeInstanceOf(AgentInUseError);
86
- if (err instanceof AgentInUseError) {
87
- expect(err.contextDir).toBe(resolve("/tmp/agent-lock-7"));
88
- }
89
- } finally {
90
- lock.release();
91
- }
92
- });
93
- });
package/src/lock.ts DELETED
@@ -1,57 +0,0 @@
1
- // Process-wide registry of held context-directory locks.
2
- //
3
- // The agent enforces a runtime singleton-per-contextDir invariant: at most one
4
- // in-process agent may own a given context directory at a time. Holding two
5
- // agents against the same directory simultaneously corrupts both the git
6
- // state and the audit collector's bookkeeping.
7
- //
8
- // This is a best-effort in-process check. It does not coordinate across OS
9
- // processes, and it compares lexically-resolved absolute paths — two paths
10
- // that point to the same directory through symlinks or `..`/`/./` segments
11
- // are normalized by `path.resolve`, but a hard link or a separately mounted
12
- // bind to the same inode will not be detected. Callers passing their own
13
- // `contextStore` (rather than a `contextDir` string) bypass the lock; they
14
- // are responsible for their store's lifetime.
15
-
16
- import { resolve } from "node:path";
17
-
18
- const heldLocks = new Set<string>();
19
-
20
- export class AgentInUseError extends Error {
21
- readonly contextDir: string;
22
-
23
- constructor(contextDir: string) {
24
- super(`an agent is already open for context directory: ${contextDir}`);
25
- this.name = "AgentInUseError";
26
- this.contextDir = contextDir;
27
- }
28
- }
29
-
30
- export type ContextDirLock = {
31
- /** Absolute, resolved path of the locked directory. */
32
- readonly path: string;
33
- /** Release the lock. Idempotent. */
34
- release(): void;
35
- };
36
-
37
- /**
38
- * Acquire the process-wide lock for `contextDir`. Throws `AgentInUseError`
39
- * if another agent already holds it. The returned `release` is idempotent.
40
- */
41
- export function acquireContextDirLock(contextDir: string): ContextDirLock {
42
- const path = resolve(contextDir);
43
- if (heldLocks.has(path)) {
44
- throw new AgentInUseError(path);
45
- }
46
- heldLocks.add(path);
47
-
48
- let released = false;
49
- return {
50
- path,
51
- release() {
52
- if (released) return;
53
- released = true;
54
- heldLocks.delete(path);
55
- },
56
- };
57
- }