@lawrence369/loop-cli 0.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent/activity.d.ts +64 -0
- package/dist/agent/activity.js +265 -0
- package/dist/agent/launcher.d.ts +42 -0
- package/dist/agent/launcher.js +243 -0
- package/dist/agent/pty-session.d.ts +113 -0
- package/dist/agent/pty-session.js +490 -0
- package/dist/agent/ready-detector.d.ts +46 -0
- package/dist/agent/ready-detector.js +86 -0
- package/dist/agent/wrapper.d.ts +18 -0
- package/dist/agent/wrapper.js +110 -0
- package/dist/bin/lclaude.d.ts +3 -0
- package/dist/bin/lclaude.js +7 -0
- package/dist/bin/lcodex.d.ts +3 -0
- package/dist/bin/lcodex.js +7 -0
- package/dist/bin/lgemini.d.ts +3 -0
- package/dist/bin/lgemini.js +7 -0
- package/dist/bus/daemon.d.ts +56 -0
- package/dist/bus/daemon.js +135 -0
- package/dist/bus/event-bus.d.ts +105 -0
- package/dist/bus/event-bus.js +157 -0
- package/dist/bus/message.d.ts +48 -0
- package/dist/bus/message.js +129 -0
- package/dist/bus/queue.d.ts +50 -0
- package/dist/bus/queue.js +100 -0
- package/dist/bus/store.d.ts +88 -0
- package/dist/bus/store.js +212 -0
- package/dist/bus/subscriber.d.ts +76 -0
- package/dist/bus/subscriber.js +187 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +72 -0
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.js +58 -0
- package/dist/core/conversation.d.ts +34 -0
- package/dist/core/conversation.js +289 -0
- package/dist/core/engine.d.ts +40 -0
- package/dist/core/engine.js +288 -0
- package/dist/core/loop.d.ts +33 -0
- package/dist/core/loop.js +209 -0
- package/dist/core/protocol.d.ts +60 -0
- package/dist/core/protocol.js +162 -0
- package/dist/core/scoring.d.ts +34 -0
- package/dist/core/scoring.js +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +408 -0
- package/dist/orchestrator/daemon.d.ts +74 -0
- package/dist/orchestrator/daemon.js +294 -0
- package/dist/orchestrator/group.d.ts +73 -0
- package/dist/orchestrator/group.js +166 -0
- package/dist/orchestrator/ipc-server.d.ts +60 -0
- package/dist/orchestrator/ipc-server.js +166 -0
- package/dist/orchestrator/scheduler.d.ts +32 -0
- package/dist/orchestrator/scheduler.js +95 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +42 -0
- package/dist/plan/decisions.d.ts +18 -0
- package/dist/plan/decisions.js +143 -0
- package/dist/plan/shared-plan.d.ts +33 -0
- package/dist/plan/shared-plan.js +211 -0
- package/dist/skills/executor.d.ts +7 -0
- package/dist/skills/executor.js +11 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +54 -0
- package/dist/terminal/adapter.d.ts +61 -0
- package/dist/terminal/adapter.js +42 -0
- package/dist/terminal/detect.d.ts +30 -0
- package/dist/terminal/detect.js +77 -0
- package/dist/terminal/iterm2-adapter.d.ts +19 -0
- package/dist/terminal/iterm2-adapter.js +120 -0
- package/dist/terminal/pty-adapter.d.ts +18 -0
- package/dist/terminal/pty-adapter.js +84 -0
- package/dist/terminal/terminal-adapter.d.ts +17 -0
- package/dist/terminal/terminal-adapter.js +94 -0
- package/dist/terminal/tmux-adapter.d.ts +18 -0
- package/dist/terminal/tmux-adapter.js +127 -0
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.js +145 -0
- package/dist/ui/colors.d.ts +41 -0
- package/dist/ui/colors.js +65 -0
- package/dist/ui/dashboard.d.ts +32 -0
- package/dist/ui/dashboard.js +138 -0
- package/dist/ui/input.d.ts +10 -0
- package/dist/ui/input.js +96 -0
- package/dist/ui/interactive.d.ts +13 -0
- package/dist/ui/interactive.js +230 -0
- package/dist/ui/renderer.d.ts +33 -0
- package/dist/ui/renderer.js +106 -0
- package/dist/utils/ansi.d.ts +11 -0
- package/dist/utils/ansi.js +16 -0
- package/dist/utils/fs.d.ts +34 -0
- package/dist/utils/fs.js +115 -0
- package/dist/utils/lock.d.ts +12 -0
- package/dist/utils/lock.js +116 -0
- package/dist/utils/process.d.ts +31 -0
- package/dist/utils/process.js +111 -0
- package/dist/utils/pty-filter.d.ts +31 -0
- package/dist/utils/pty-filter.js +187 -0
- package/package.json +71 -0
- package/skills/loop/SKILL.md +19 -0
- package/skills/plan/SKILL.md +9 -0
- package/skills/review/SKILL.md +14 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { nextSeq } from "../utils/lock.js";
|
|
3
|
+
import { appendJsonl } from "../utils/fs.js";
|
|
4
|
+
import { QueueManager } from "./queue.js";
|
|
5
|
+
/**
|
|
6
|
+
* Manages message creation, routing, and delivery.
|
|
7
|
+
*
|
|
8
|
+
* Target resolution order:
|
|
9
|
+
* 1. Exact subscriber ID match (e.g. "claude:abc123")
|
|
10
|
+
* 2. Nickname match (e.g. "claude-1")
|
|
11
|
+
* 3. Agent type match (e.g. "claude" -> all active claude agents)
|
|
12
|
+
* 4. Broadcast ("*" -> all active agents)
|
|
13
|
+
*/
|
|
14
|
+
export class MessageManager {
|
|
15
|
+
eventsDir;
|
|
16
|
+
seqFile;
|
|
17
|
+
seqLockFile;
|
|
18
|
+
subscriberManager;
|
|
19
|
+
queueManager;
|
|
20
|
+
constructor(busDir, subscriberManager) {
|
|
21
|
+
this.eventsDir = join(busDir, "events");
|
|
22
|
+
this.seqFile = join(busDir, "seq.counter");
|
|
23
|
+
this.seqLockFile = join(busDir, "seq.counter.lock");
|
|
24
|
+
this.subscriberManager = subscriberManager;
|
|
25
|
+
this.queueManager = new QueueManager(busDir);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Get the next monotonically increasing sequence number.
|
|
29
|
+
*/
|
|
30
|
+
async nextSeq() {
|
|
31
|
+
return nextSeq(this.seqFile, this.seqLockFile);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a target string to a list of subscriber IDs.
|
|
35
|
+
*
|
|
36
|
+
* Resolution order:
|
|
37
|
+
* 1. Exact subscriber ID match
|
|
38
|
+
* 2. Nickname match
|
|
39
|
+
* 3. Agent type match (all active agents of that type)
|
|
40
|
+
* 4. Broadcast "*" (all active agents)
|
|
41
|
+
*/
|
|
42
|
+
async resolveTarget(target) {
|
|
43
|
+
const agents = await this.subscriberManager.list();
|
|
44
|
+
// 1. Exact subscriber ID match
|
|
45
|
+
if (agents.has(target)) {
|
|
46
|
+
return [target];
|
|
47
|
+
}
|
|
48
|
+
// 2. If contains ":", treat as subscriber ID even if not in registry
|
|
49
|
+
if (target.includes(":")) {
|
|
50
|
+
return [target];
|
|
51
|
+
}
|
|
52
|
+
// 3. Nickname match
|
|
53
|
+
for (const [id, meta] of agents) {
|
|
54
|
+
if (meta.nickname === target && meta.status === "active") {
|
|
55
|
+
return [id];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// 4. Agent type match
|
|
59
|
+
const byType = [];
|
|
60
|
+
for (const [id, meta] of agents) {
|
|
61
|
+
if (meta.agent_type === target && meta.status === "active") {
|
|
62
|
+
byType.push(id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (byType.length > 0) {
|
|
66
|
+
return byType;
|
|
67
|
+
}
|
|
68
|
+
// 5. Broadcast
|
|
69
|
+
if (target === "*") {
|
|
70
|
+
const all = [];
|
|
71
|
+
for (const [id, meta] of agents) {
|
|
72
|
+
if (meta.status === "active") {
|
|
73
|
+
all.push(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return all;
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Route an event to its target subscribers' queues.
|
|
82
|
+
*/
|
|
83
|
+
async route(event) {
|
|
84
|
+
const targets = await this.resolveTarget(event.target);
|
|
85
|
+
for (const subscriberId of targets) {
|
|
86
|
+
// Check offset to avoid re-delivering already-consumed events
|
|
87
|
+
const offset = await this.queueManager.getOffset(subscriberId);
|
|
88
|
+
if (event.seq > offset) {
|
|
89
|
+
await this.queueManager.enqueue(subscriberId, event);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create and persist a new event, then route it to targets.
|
|
95
|
+
* Returns the created event.
|
|
96
|
+
*/
|
|
97
|
+
async createEvent(publisher, target, data, type = "message/targeted") {
|
|
98
|
+
const seq = await this.nextSeq();
|
|
99
|
+
const timestamp = new Date().toISOString();
|
|
100
|
+
const date = timestamp.slice(0, 10);
|
|
101
|
+
// Resolve to verify target exists
|
|
102
|
+
const targets = await this.resolveTarget(target);
|
|
103
|
+
if (targets.length === 0) {
|
|
104
|
+
throw new Error(`Target "${target}" not found`);
|
|
105
|
+
}
|
|
106
|
+
const event = {
|
|
107
|
+
seq,
|
|
108
|
+
timestamp,
|
|
109
|
+
type,
|
|
110
|
+
event: data.message !== undefined ? "message" : "event",
|
|
111
|
+
publisher,
|
|
112
|
+
target,
|
|
113
|
+
data,
|
|
114
|
+
};
|
|
115
|
+
// Write to the event log
|
|
116
|
+
const eventFile = join(this.eventsDir, `${date}.jsonl`);
|
|
117
|
+
await appendJsonl(eventFile, event);
|
|
118
|
+
// Route to target queues
|
|
119
|
+
await this.route(event);
|
|
120
|
+
return event;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get the queue manager (needed by EventBus for consume operations).
|
|
124
|
+
*/
|
|
125
|
+
getQueueManager() {
|
|
126
|
+
return this.queueManager;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=message.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BusEvent } from "./event-bus.js";
|
|
2
|
+
/**
|
|
3
|
+
* Manages per-subscriber message queues.
|
|
4
|
+
*
|
|
5
|
+
* Each subscriber has a directory under `bus/queues/{safeName}/`
|
|
6
|
+
* containing a `pending.jsonl` file with queued events.
|
|
7
|
+
*/
|
|
8
|
+
export declare class QueueManager {
|
|
9
|
+
readonly busDir: string;
|
|
10
|
+
private readonly queuesDir;
|
|
11
|
+
private readonly offsetsDir;
|
|
12
|
+
constructor(busDir: string);
|
|
13
|
+
/**
|
|
14
|
+
* Enqueue an event for a subscriber.
|
|
15
|
+
*/
|
|
16
|
+
enqueue(subscriberId: string, event: BusEvent): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Dequeue all pending events for a subscriber (read and clear).
|
|
19
|
+
*/
|
|
20
|
+
dequeue(subscriberId: string): Promise<BusEvent[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Peek at pending events without removing them.
|
|
23
|
+
*/
|
|
24
|
+
peek(subscriberId: string): Promise<BusEvent[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Clear all pending events for a subscriber.
|
|
27
|
+
*/
|
|
28
|
+
clear(subscriberId: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Ensure the queue directory exists for a subscriber.
|
|
31
|
+
*/
|
|
32
|
+
ensureQueue(subscriberId: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Get the path to a subscriber's pending file.
|
|
35
|
+
*/
|
|
36
|
+
getPendingPath(subscriberId: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Get the path to a subscriber's queue directory.
|
|
39
|
+
*/
|
|
40
|
+
getQueueDir(subscriberId: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Get the consumption offset for a subscriber.
|
|
43
|
+
*/
|
|
44
|
+
getOffset(subscriberId: string): Promise<number>;
|
|
45
|
+
/**
|
|
46
|
+
* Set the consumption offset for a subscriber.
|
|
47
|
+
*/
|
|
48
|
+
setOffset(subscriberId: string, seq: number): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=queue.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ensureDir, appendJsonl, readJsonl, truncateFile } from "../utils/fs.js";
|
|
3
|
+
import { subscriberToSafeName } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Manages per-subscriber message queues.
|
|
6
|
+
*
|
|
7
|
+
* Each subscriber has a directory under `bus/queues/{safeName}/`
|
|
8
|
+
* containing a `pending.jsonl` file with queued events.
|
|
9
|
+
*/
|
|
10
|
+
export class QueueManager {
|
|
11
|
+
busDir;
|
|
12
|
+
queuesDir;
|
|
13
|
+
offsetsDir;
|
|
14
|
+
constructor(busDir) {
|
|
15
|
+
this.busDir = busDir;
|
|
16
|
+
this.queuesDir = join(busDir, "queues");
|
|
17
|
+
this.offsetsDir = join(busDir, "offsets");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Enqueue an event for a subscriber.
|
|
21
|
+
*/
|
|
22
|
+
async enqueue(subscriberId, event) {
|
|
23
|
+
await this.ensureQueue(subscriberId);
|
|
24
|
+
const pendingPath = this.getPendingPath(subscriberId);
|
|
25
|
+
await appendJsonl(pendingPath, event);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Dequeue all pending events for a subscriber (read and clear).
|
|
29
|
+
*/
|
|
30
|
+
async dequeue(subscriberId) {
|
|
31
|
+
const pendingPath = this.getPendingPath(subscriberId);
|
|
32
|
+
const events = await readJsonl(pendingPath);
|
|
33
|
+
if (events.length > 0) {
|
|
34
|
+
await truncateFile(pendingPath);
|
|
35
|
+
}
|
|
36
|
+
return events;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Peek at pending events without removing them.
|
|
40
|
+
*/
|
|
41
|
+
async peek(subscriberId) {
|
|
42
|
+
const pendingPath = this.getPendingPath(subscriberId);
|
|
43
|
+
return readJsonl(pendingPath);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Clear all pending events for a subscriber.
|
|
47
|
+
*/
|
|
48
|
+
async clear(subscriberId) {
|
|
49
|
+
const pendingPath = this.getPendingPath(subscriberId);
|
|
50
|
+
await truncateFile(pendingPath);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ensure the queue directory exists for a subscriber.
|
|
54
|
+
*/
|
|
55
|
+
async ensureQueue(subscriberId) {
|
|
56
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
57
|
+
await ensureDir(join(this.queuesDir, safeName));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get the path to a subscriber's pending file.
|
|
61
|
+
*/
|
|
62
|
+
getPendingPath(subscriberId) {
|
|
63
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
64
|
+
return join(this.queuesDir, safeName, "pending.jsonl");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the path to a subscriber's queue directory.
|
|
68
|
+
*/
|
|
69
|
+
getQueueDir(subscriberId) {
|
|
70
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
71
|
+
return join(this.queuesDir, safeName);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get the consumption offset for a subscriber.
|
|
75
|
+
*/
|
|
76
|
+
async getOffset(subscriberId) {
|
|
77
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
78
|
+
const offsetPath = join(this.offsetsDir, `${safeName}.offset`);
|
|
79
|
+
try {
|
|
80
|
+
const { readFile } = await import("node:fs/promises");
|
|
81
|
+
const content = await readFile(offsetPath, "utf8");
|
|
82
|
+
const parsed = parseInt(content.trim(), 10);
|
|
83
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Set the consumption offset for a subscriber.
|
|
91
|
+
*/
|
|
92
|
+
async setOffset(subscriberId, seq) {
|
|
93
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
94
|
+
const offsetPath = join(this.offsetsDir, `${safeName}.offset`);
|
|
95
|
+
await ensureDir(this.offsetsDir);
|
|
96
|
+
const { writeFile } = await import("node:fs/promises");
|
|
97
|
+
await writeFile(offsetPath, `${seq}\n`, "utf8");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=queue.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { BusEvent } from "./event-bus.js";
|
|
2
|
+
import type { AgentMetadata } from "./subscriber.js";
|
|
3
|
+
/**
|
|
4
|
+
* File-backed store for the event bus.
|
|
5
|
+
*
|
|
6
|
+
* Layout under `<project>/.loop/`:
|
|
7
|
+
* bus/events/{YYYY-MM-DD}.jsonl - append-only event log
|
|
8
|
+
* bus/queues/{subscriberId}/
|
|
9
|
+
* pending.jsonl - pending messages per subscriber
|
|
10
|
+
* tty - agent TTY path
|
|
11
|
+
* bus/offsets/{subscriberId}.offset - consumption offset
|
|
12
|
+
* bus/seq.counter - monotonic sequence number
|
|
13
|
+
* bus/seq.counter.lock - file lock for counter
|
|
14
|
+
* agents/all-agents.json - agent registry
|
|
15
|
+
*/
|
|
16
|
+
export declare class BusStore {
|
|
17
|
+
readonly busDir: string;
|
|
18
|
+
readonly eventsDir: string;
|
|
19
|
+
readonly queuesDir: string;
|
|
20
|
+
readonly offsetsDir: string;
|
|
21
|
+
readonly agentsDir: string;
|
|
22
|
+
readonly agentsFile: string;
|
|
23
|
+
readonly seqCounterPath: string;
|
|
24
|
+
readonly seqLockPath: string;
|
|
25
|
+
readonly runDir: string;
|
|
26
|
+
private _eventCount;
|
|
27
|
+
constructor(busDir: string);
|
|
28
|
+
/**
|
|
29
|
+
* Ensure all required directories exist.
|
|
30
|
+
*/
|
|
31
|
+
init(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Append an event to the daily event log.
|
|
34
|
+
*/
|
|
35
|
+
appendEvent(event: BusEvent): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Append an event to a subscriber's pending queue.
|
|
38
|
+
*/
|
|
39
|
+
appendToQueue(subscriberId: string, event: BusEvent): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Read and clear a subscriber's pending queue (atomic drain).
|
|
42
|
+
*/
|
|
43
|
+
consumeQueue(subscriberId: string): Promise<BusEvent[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Read a subscriber's pending queue without clearing it.
|
|
46
|
+
*/
|
|
47
|
+
peekQueue(subscriberId: string): Promise<BusEvent[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Get a subscriber's consumption offset.
|
|
50
|
+
*/
|
|
51
|
+
getOffset(subscriberId: string): Promise<number>;
|
|
52
|
+
/**
|
|
53
|
+
* Set a subscriber's consumption offset.
|
|
54
|
+
*/
|
|
55
|
+
setOffset(subscriberId: string, seq: number): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Load the agents registry.
|
|
58
|
+
*/
|
|
59
|
+
loadAgents(): Promise<Map<string, AgentMetadata>>;
|
|
60
|
+
/**
|
|
61
|
+
* Save the agents registry.
|
|
62
|
+
*/
|
|
63
|
+
saveAgents(agents: Map<string, AgentMetadata>): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Count total events across all event log files.
|
|
66
|
+
* Uses a cached counter when available (warm path).
|
|
67
|
+
*/
|
|
68
|
+
countEvents(): Promise<number>;
|
|
69
|
+
/**
|
|
70
|
+
* Scan all event log files on disk and return the total event count.
|
|
71
|
+
*/
|
|
72
|
+
private _countEventsFromDisk;
|
|
73
|
+
/**
|
|
74
|
+
* Ensure a subscriber's queue directory exists.
|
|
75
|
+
*/
|
|
76
|
+
ensureQueue(subscriberId: string): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Convert a subscriber ID to a safe directory name.
|
|
80
|
+
* Replaces ":" with "_" so it can be used in file paths.
|
|
81
|
+
*/
|
|
82
|
+
export declare function subscriberToSafeName(subscriberId: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* Convert a safe directory name back to a subscriber ID.
|
|
85
|
+
* Replaces the first "_" with ":" to reconstruct the original format.
|
|
86
|
+
*/
|
|
87
|
+
export declare function safeNameToSubscriber(safeName: string): string;
|
|
88
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ensureDir, appendJsonl, readJsonl, safeWriteFile, safeReadFile, fileExists } from "../utils/fs.js";
|
|
3
|
+
/**
|
|
4
|
+
* File-backed store for the event bus.
|
|
5
|
+
*
|
|
6
|
+
* Layout under `<project>/.loop/`:
|
|
7
|
+
* bus/events/{YYYY-MM-DD}.jsonl - append-only event log
|
|
8
|
+
* bus/queues/{subscriberId}/
|
|
9
|
+
* pending.jsonl - pending messages per subscriber
|
|
10
|
+
* tty - agent TTY path
|
|
11
|
+
* bus/offsets/{subscriberId}.offset - consumption offset
|
|
12
|
+
* bus/seq.counter - monotonic sequence number
|
|
13
|
+
* bus/seq.counter.lock - file lock for counter
|
|
14
|
+
* agents/all-agents.json - agent registry
|
|
15
|
+
*/
|
|
16
|
+
export class BusStore {
|
|
17
|
+
busDir;
|
|
18
|
+
eventsDir;
|
|
19
|
+
queuesDir;
|
|
20
|
+
offsetsDir;
|
|
21
|
+
agentsDir;
|
|
22
|
+
agentsFile;
|
|
23
|
+
seqCounterPath;
|
|
24
|
+
seqLockPath;
|
|
25
|
+
runDir;
|
|
26
|
+
_eventCount = 0;
|
|
27
|
+
constructor(busDir) {
|
|
28
|
+
this.busDir = busDir;
|
|
29
|
+
// Bus subdirectories live under busDir
|
|
30
|
+
this.eventsDir = join(busDir, "events");
|
|
31
|
+
this.queuesDir = join(busDir, "queues");
|
|
32
|
+
this.offsetsDir = join(busDir, "offsets");
|
|
33
|
+
this.seqCounterPath = join(busDir, "seq.counter");
|
|
34
|
+
this.seqLockPath = join(busDir, "seq.counter.lock");
|
|
35
|
+
// Agents directory is a sibling of bus/ under .loop/
|
|
36
|
+
const loopDir = join(busDir, "..");
|
|
37
|
+
this.agentsDir = join(loopDir, "agents");
|
|
38
|
+
this.agentsFile = join(this.agentsDir, "all-agents.json");
|
|
39
|
+
this.runDir = join(loopDir, "run");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Ensure all required directories exist.
|
|
43
|
+
*/
|
|
44
|
+
async init() {
|
|
45
|
+
await Promise.all([
|
|
46
|
+
ensureDir(this.eventsDir),
|
|
47
|
+
ensureDir(this.queuesDir),
|
|
48
|
+
ensureDir(this.offsetsDir),
|
|
49
|
+
ensureDir(this.agentsDir),
|
|
50
|
+
ensureDir(this.runDir),
|
|
51
|
+
]);
|
|
52
|
+
// Create the agents file if it doesn't exist
|
|
53
|
+
if (!(await fileExists(this.agentsFile))) {
|
|
54
|
+
const initial = {
|
|
55
|
+
created_at: new Date().toISOString(),
|
|
56
|
+
agents: {},
|
|
57
|
+
};
|
|
58
|
+
await safeWriteFile(this.agentsFile, JSON.stringify(initial, null, 2) + "\n");
|
|
59
|
+
}
|
|
60
|
+
// Initialize event count cache from disk
|
|
61
|
+
this._eventCount = await this._countEventsFromDisk();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Append an event to the daily event log.
|
|
65
|
+
*/
|
|
66
|
+
async appendEvent(event) {
|
|
67
|
+
const date = event.timestamp.slice(0, 10); // YYYY-MM-DD
|
|
68
|
+
const filePath = join(this.eventsDir, `${date}.jsonl`);
|
|
69
|
+
await appendJsonl(filePath, event);
|
|
70
|
+
this._eventCount++;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Append an event to a subscriber's pending queue.
|
|
74
|
+
*/
|
|
75
|
+
async appendToQueue(subscriberId, event) {
|
|
76
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
77
|
+
const queueDir = join(this.queuesDir, safeName);
|
|
78
|
+
await ensureDir(queueDir);
|
|
79
|
+
const pendingPath = join(queueDir, "pending.jsonl");
|
|
80
|
+
await appendJsonl(pendingPath, event);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read and clear a subscriber's pending queue (atomic drain).
|
|
84
|
+
*/
|
|
85
|
+
async consumeQueue(subscriberId) {
|
|
86
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
87
|
+
const pendingPath = join(this.queuesDir, safeName, "pending.jsonl");
|
|
88
|
+
const events = await readJsonl(pendingPath);
|
|
89
|
+
if (events.length > 0) {
|
|
90
|
+
// Truncate the file after reading
|
|
91
|
+
await safeWriteFile(pendingPath, "");
|
|
92
|
+
}
|
|
93
|
+
return events;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read a subscriber's pending queue without clearing it.
|
|
97
|
+
*/
|
|
98
|
+
async peekQueue(subscriberId) {
|
|
99
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
100
|
+
const pendingPath = join(this.queuesDir, safeName, "pending.jsonl");
|
|
101
|
+
return readJsonl(pendingPath);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get a subscriber's consumption offset.
|
|
105
|
+
*/
|
|
106
|
+
async getOffset(subscriberId) {
|
|
107
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
108
|
+
const offsetPath = join(this.offsetsDir, `${safeName}.offset`);
|
|
109
|
+
const content = await safeReadFile(offsetPath);
|
|
110
|
+
if (content === null)
|
|
111
|
+
return 0;
|
|
112
|
+
const parsed = parseInt(content.trim(), 10);
|
|
113
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Set a subscriber's consumption offset.
|
|
117
|
+
*/
|
|
118
|
+
async setOffset(subscriberId, seq) {
|
|
119
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
120
|
+
const offsetPath = join(this.offsetsDir, `${safeName}.offset`);
|
|
121
|
+
await ensureDir(this.offsetsDir);
|
|
122
|
+
await safeWriteFile(offsetPath, `${seq}\n`);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Load the agents registry.
|
|
126
|
+
*/
|
|
127
|
+
async loadAgents() {
|
|
128
|
+
const content = await safeReadFile(this.agentsFile);
|
|
129
|
+
if (content === null)
|
|
130
|
+
return new Map();
|
|
131
|
+
try {
|
|
132
|
+
const data = JSON.parse(content);
|
|
133
|
+
const agents = data.agents ?? {};
|
|
134
|
+
return new Map(Object.entries(agents));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return new Map();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Save the agents registry.
|
|
142
|
+
*/
|
|
143
|
+
async saveAgents(agents) {
|
|
144
|
+
const existing = await safeReadFile(this.agentsFile);
|
|
145
|
+
let data;
|
|
146
|
+
try {
|
|
147
|
+
data = existing ? JSON.parse(existing) : { created_at: new Date().toISOString(), agents: {} };
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
data = { created_at: new Date().toISOString(), agents: {} };
|
|
151
|
+
}
|
|
152
|
+
data.agents = Object.fromEntries(agents);
|
|
153
|
+
await safeWriteFile(this.agentsFile, JSON.stringify(data, null, 2) + "\n");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Count total events across all event log files.
|
|
157
|
+
* Uses a cached counter when available (warm path).
|
|
158
|
+
*/
|
|
159
|
+
async countEvents() {
|
|
160
|
+
if (this._eventCount > 0)
|
|
161
|
+
return this._eventCount;
|
|
162
|
+
this._eventCount = await this._countEventsFromDisk();
|
|
163
|
+
return this._eventCount;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Scan all event log files on disk and return the total event count.
|
|
167
|
+
*/
|
|
168
|
+
async _countEventsFromDisk() {
|
|
169
|
+
const { readdir, readFile } = await import("node:fs/promises");
|
|
170
|
+
let total = 0;
|
|
171
|
+
try {
|
|
172
|
+
const files = await readdir(this.eventsDir);
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (!file.endsWith(".jsonl"))
|
|
175
|
+
continue;
|
|
176
|
+
const content = await readFile(join(this.eventsDir, file), "utf8");
|
|
177
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
178
|
+
total += lines.length;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Events dir may not exist yet
|
|
183
|
+
}
|
|
184
|
+
return total;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Ensure a subscriber's queue directory exists.
|
|
188
|
+
*/
|
|
189
|
+
async ensureQueue(subscriberId) {
|
|
190
|
+
const safeName = subscriberToSafeName(subscriberId);
|
|
191
|
+
await ensureDir(join(this.queuesDir, safeName));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Convert a subscriber ID to a safe directory name.
|
|
196
|
+
* Replaces ":" with "_" so it can be used in file paths.
|
|
197
|
+
*/
|
|
198
|
+
export function subscriberToSafeName(subscriberId) {
|
|
199
|
+
// Strip path separators and traversal sequences to prevent directory escape
|
|
200
|
+
return subscriberId.replace(/:/g, "_").replace(/[/\\]/g, "").replace(/\.\./g, "");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Convert a safe directory name back to a subscriber ID.
|
|
204
|
+
* Replaces the first "_" with ":" to reconstruct the original format.
|
|
205
|
+
*/
|
|
206
|
+
export function safeNameToSubscriber(safeName) {
|
|
207
|
+
const idx = safeName.indexOf("_");
|
|
208
|
+
if (idx === -1)
|
|
209
|
+
return safeName;
|
|
210
|
+
return safeName.slice(0, idx) + ":" + safeName.slice(idx + 1);
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BusStore } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Metadata tracked for each agent on the bus.
|
|
4
|
+
*/
|
|
5
|
+
export interface AgentMetadata {
|
|
6
|
+
agent_type: string;
|
|
7
|
+
nickname: string;
|
|
8
|
+
status: "active" | "inactive";
|
|
9
|
+
joined_at: string;
|
|
10
|
+
last_seen: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
tty?: string;
|
|
13
|
+
tmux_pane?: string;
|
|
14
|
+
launch_mode: string;
|
|
15
|
+
activity_state: string;
|
|
16
|
+
last_activity?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Manages subscriber registration, lifecycle, and metadata.
|
|
20
|
+
*
|
|
21
|
+
* Subscriber IDs follow the format `{agentType}:{randomId}`,
|
|
22
|
+
* e.g. "claude:abc12345".
|
|
23
|
+
*/
|
|
24
|
+
export declare class SubscriberManager {
|
|
25
|
+
private store;
|
|
26
|
+
private agents;
|
|
27
|
+
constructor(store: BusStore);
|
|
28
|
+
/**
|
|
29
|
+
* Load agent data from disk into memory.
|
|
30
|
+
*/
|
|
31
|
+
load(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Persist agent data to disk.
|
|
34
|
+
*/
|
|
35
|
+
save(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Register a new agent on the bus.
|
|
38
|
+
* Returns the generated subscriber ID.
|
|
39
|
+
*/
|
|
40
|
+
register(agentType: string, metadata?: Partial<AgentMetadata>): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Unregister an agent, marking it as inactive.
|
|
43
|
+
*/
|
|
44
|
+
unregister(subscriberId: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Rename a subscriber's nickname.
|
|
47
|
+
*/
|
|
48
|
+
rename(subscriberId: string, nickname: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Update specific metadata fields for a subscriber.
|
|
51
|
+
*/
|
|
52
|
+
updateMetadata(subscriberId: string, updates: Partial<AgentMetadata>): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Clean up subscribers whose processes are no longer alive.
|
|
55
|
+
* Returns the list of subscriber IDs that were marked inactive.
|
|
56
|
+
*/
|
|
57
|
+
cleanupInactive(): Promise<string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* List all agents (both active and inactive).
|
|
60
|
+
*/
|
|
61
|
+
list(): Promise<Map<string, AgentMetadata>>;
|
|
62
|
+
/**
|
|
63
|
+
* Get metadata for a specific subscriber.
|
|
64
|
+
*/
|
|
65
|
+
get(subscriberId: string): Promise<AgentMetadata | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Generate a unique auto-nickname for the given agent type.
|
|
68
|
+
* Format: {prefix}-{N} where N is the lowest unused integer.
|
|
69
|
+
*/
|
|
70
|
+
private generateNickname;
|
|
71
|
+
/**
|
|
72
|
+
* Get the nickname prefix for a given agent type.
|
|
73
|
+
*/
|
|
74
|
+
private nicknamePrefix;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=subscriber.d.ts.map
|