@micsushi/agent-hotline 0.5.1

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.
@@ -0,0 +1,324 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+
6
+ const { resolveProjectRoot } = require("./hook-input-parser");
7
+
8
+ function pathBasename(value) {
9
+ return (
10
+ String(value)
11
+ .replace(/[\\/]+$/, "")
12
+ .split(/[\\/]/)
13
+ .pop() || undefined
14
+ );
15
+ }
16
+
17
+ const SOURCE_APPS = new Set(["Codex", "Claude", "Antigravity"]);
18
+ const STATUSES = new Set(["pending", "playing", "played", "skipped"]);
19
+
20
+ function defaultDataDir() {
21
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
22
+ return path.join(appData, "Agent Hotline");
23
+ }
24
+
25
+ function defaultQueueFile() {
26
+ return path.join(defaultDataDir(), "speech-queue.json");
27
+ }
28
+
29
+ function safeReadJson(filePath) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return { items: [] };
34
+ return { items: [] };
35
+ }
36
+ }
37
+
38
+ function writeJson(filePath, value) {
39
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
40
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
41
+ }
42
+
43
+ function clone(value) {
44
+ return JSON.parse(JSON.stringify(value));
45
+ }
46
+
47
+ function createDefaultId() {
48
+ if (typeof crypto.randomUUID === "function") {
49
+ return crypto.randomUUID();
50
+ }
51
+ return crypto.randomBytes(16).toString("hex");
52
+ }
53
+
54
+ function normalizeString(value, fieldName) {
55
+ if (typeof value !== "string" || value.trim() === "") {
56
+ throw new Error(`${fieldName} must be a non-empty string`);
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function normalizeSourceApp(value) {
62
+ const sourceApp = normalizeString(value, "sourceApp");
63
+ if (!SOURCE_APPS.has(sourceApp)) {
64
+ throw new Error("sourceApp must be Codex, Claude, or Antigravity");
65
+ }
66
+ return sourceApp;
67
+ }
68
+
69
+ function createSpeechQueueStore(options = {}) {
70
+ const filePath =
71
+ options.filePath || path.join(options.dataDir || defaultDataDir(), "speech-queue.json");
72
+ const now = options.now || (() => new Date().toISOString());
73
+ const idGenerator = options.idGenerator || createDefaultId;
74
+
75
+ let state = loadState();
76
+ resetPersistedPlayingItems();
77
+ migrateProjectRoots();
78
+
79
+ function loadState() {
80
+ const data = safeReadJson(filePath);
81
+ const items = Array.isArray(data.items) ? data.items.filter(isValidPersistedItem) : [];
82
+ return { items };
83
+ }
84
+
85
+ // Self-heal items whose projectPath points into a subdirectory of a repo (a
86
+ // result of shell cwd drift before the parser resolved to repo root). Collapse
87
+ // them onto the enclosing repo root so subdir work regroups under the real
88
+ // project. Only rewrites when a repo marker is actually found on disk, so
89
+ // missing/foreign paths are left untouched. Persists once if anything changed.
90
+ function migrateProjectRoots() {
91
+ let changed = false;
92
+ for (const item of state.items) {
93
+ if (!item.projectPath) continue;
94
+ const root = resolveProjectRoot(item.projectPath);
95
+ if (root && root !== item.projectPath) {
96
+ item.projectPath = root;
97
+ item.projectName = pathBasename(root);
98
+ changed = true;
99
+ }
100
+ }
101
+ if (changed) persist();
102
+ }
103
+
104
+ function resetPersistedPlayingItems() {
105
+ let changed = false;
106
+ let timestamp;
107
+ for (const item of state.items) {
108
+ if (item.status !== "playing") continue;
109
+ timestamp ||= now();
110
+ item.status = "pending";
111
+ item.timestamps.interruptedAt = timestamp;
112
+ touch(item, timestamp);
113
+ changed = true;
114
+ }
115
+ if (changed) persist();
116
+ }
117
+
118
+ function persist() {
119
+ writeJson(filePath, state);
120
+ }
121
+
122
+ function findItem(id) {
123
+ return state.items.find((item) => item.id === id);
124
+ }
125
+
126
+ function requireItem(id) {
127
+ const item = findItem(id);
128
+ if (!item) {
129
+ throw new Error(`Queue item not found: ${id}`);
130
+ }
131
+ return item;
132
+ }
133
+
134
+ function touch(item, timestamp) {
135
+ item.timestamps.updatedAt = timestamp;
136
+ }
137
+
138
+ function optionalString(value) {
139
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
140
+ }
141
+
142
+ function normalizeUserMessages(value) {
143
+ if (!Array.isArray(value)) return [];
144
+ return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
145
+ }
146
+
147
+ function enqueue(input) {
148
+ const timestamp = now();
149
+ const item = {
150
+ id: input.id || idGenerator(),
151
+ rawSource: normalizeString(input.rawSource, "rawSource"),
152
+ speakableText: normalizeString(input.speakableText, "speakableText"),
153
+ sourceApp: normalizeSourceApp(input.sourceApp),
154
+ status: "pending",
155
+ timestamps: {
156
+ createdAt: timestamp,
157
+ updatedAt: timestamp
158
+ }
159
+ };
160
+
161
+ const threadId = optionalString(input.threadId);
162
+ if (threadId) item.threadId = threadId;
163
+ const threadLabel = optionalString(input.threadLabel);
164
+ if (threadLabel) item.threadLabel = threadLabel;
165
+ const sessionName = optionalString(input.sessionName);
166
+ if (sessionName) item.sessionName = sessionName;
167
+ const projectPath = optionalString(input.projectPath);
168
+ if (projectPath) item.projectPath = projectPath;
169
+ const projectName = optionalString(input.projectName);
170
+ if (projectName) item.projectName = projectName;
171
+ const userMessages = normalizeUserMessages(input.userMessages);
172
+ if (userMessages.length) item.userMessages = userMessages;
173
+
174
+ if (findItem(item.id)) {
175
+ throw new Error(`Queue item already exists: ${item.id}`);
176
+ }
177
+
178
+ state.items.push(item);
179
+ persist();
180
+ return clone(item);
181
+ }
182
+
183
+ function getPending() {
184
+ return clone(state.items.filter((item) => item.status === "pending"));
185
+ }
186
+
187
+ function getCurrent() {
188
+ const current = state.items.find((item) => item.status === "playing") || null;
189
+ return current ? clone(current) : null;
190
+ }
191
+
192
+ function getLatest() {
193
+ const latest = state.items[state.items.length - 1] || null;
194
+ return latest ? clone(latest) : null;
195
+ }
196
+
197
+ function markPlaying(id) {
198
+ const timestamp = now();
199
+ const item = requireItem(id);
200
+
201
+ for (const candidate of state.items) {
202
+ if (candidate.status === "playing" && candidate.id !== id) {
203
+ candidate.status = "pending";
204
+ touch(candidate, timestamp);
205
+ }
206
+ }
207
+
208
+ item.status = "playing";
209
+ item.timestamps.playingAt = timestamp;
210
+ touch(item, timestamp);
211
+ persist();
212
+ return clone(item);
213
+ }
214
+
215
+ function markPlayed(id) {
216
+ const timestamp = now();
217
+ const item = requireItem(id);
218
+ item.status = "played";
219
+ item.timestamps.playedAt = timestamp;
220
+ touch(item, timestamp);
221
+ persist();
222
+ return clone(item);
223
+ }
224
+
225
+ function markSkipped(id, reason) {
226
+ const timestamp = now();
227
+ const item = requireItem(id);
228
+ item.status = "skipped";
229
+ item.skipReason = normalizeString(reason, "reason");
230
+ item.timestamps.skippedAt = timestamp;
231
+ touch(item, timestamp);
232
+ persist();
233
+ return clone(item);
234
+ }
235
+
236
+ function pushReplay(source) {
237
+ const timestamp = now();
238
+ const item = {
239
+ id: idGenerator(),
240
+ rawSource: source.rawSource,
241
+ speakableText: source.speakableText,
242
+ sourceApp: source.sourceApp,
243
+ status: "pending",
244
+ replayOf: source.id,
245
+ timestamps: {
246
+ createdAt: timestamp,
247
+ updatedAt: timestamp,
248
+ replayedAt: timestamp
249
+ }
250
+ };
251
+ if (source.threadId) item.threadId = source.threadId;
252
+ if (source.threadLabel) item.threadLabel = source.threadLabel;
253
+ if (source.sessionName) item.sessionName = source.sessionName;
254
+ if (source.projectPath) item.projectPath = source.projectPath;
255
+ if (source.projectName) item.projectName = source.projectName;
256
+ if (Array.isArray(source.userMessages) && source.userMessages.length) {
257
+ item.userMessages = source.userMessages.slice();
258
+ }
259
+
260
+ state.items.push(item);
261
+ persist();
262
+ return clone(item);
263
+ }
264
+
265
+ function replayLatest() {
266
+ const latest = [...state.items]
267
+ .reverse()
268
+ .find((item) => item.speakableText && item.status !== "skipped");
269
+ return latest ? pushReplay(latest) : null;
270
+ }
271
+
272
+ function replayItem(id) {
273
+ const source = findItem(id);
274
+ if (!source || !source.speakableText) {
275
+ return null;
276
+ }
277
+ return pushReplay(source);
278
+ }
279
+
280
+ function clearQueue() {
281
+ state = { items: [] };
282
+ persist();
283
+ return getState();
284
+ }
285
+
286
+ function getState() {
287
+ return clone(state);
288
+ }
289
+
290
+ return {
291
+ filePath,
292
+ enqueue,
293
+ getPending,
294
+ getCurrent,
295
+ getLatest,
296
+ markPlaying,
297
+ markPlayed,
298
+ markSkipped,
299
+ replayLatest,
300
+ replayItem,
301
+ clearQueue,
302
+ getState
303
+ };
304
+ }
305
+
306
+ function isValidPersistedItem(item) {
307
+ return Boolean(
308
+ item &&
309
+ typeof item.id === "string" &&
310
+ typeof item.rawSource === "string" &&
311
+ typeof item.speakableText === "string" &&
312
+ SOURCE_APPS.has(item.sourceApp) &&
313
+ STATUSES.has(item.status) &&
314
+ item.timestamps &&
315
+ typeof item.timestamps.createdAt === "string" &&
316
+ typeof item.timestamps.updatedAt === "string"
317
+ );
318
+ }
319
+
320
+ module.exports = {
321
+ createSpeechQueueStore,
322
+ defaultDataDir,
323
+ defaultQueueFile
324
+ };
@@ -0,0 +1,80 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { defaultDataDir } = require("./speech-queue-store");
5
+
6
+ function defaultSpoolFile() {
7
+ return path.join(defaultDataDir(), "spool.json");
8
+ }
9
+
10
+ function readItems(filePath) {
11
+ try {
12
+ const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
13
+ return Array.isArray(data.items) ? data.items : [];
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ function writeItems(filePath, items) {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ const tempFile = `${filePath}.${process.pid}.tmp`;
22
+ fs.writeFileSync(tempFile, `${JSON.stringify({ items }, null, 2)}\n`, "utf8");
23
+ fs.renameSync(tempFile, filePath);
24
+ }
25
+
26
+ function createSpoolStore(options = {}) {
27
+ const filePath = options.filePath || path.join(options.dataDir || defaultDataDir(), "spool.json");
28
+ const now = options.now || (() => new Date().toISOString());
29
+
30
+ return {
31
+ filePath,
32
+ read() {
33
+ return readItems(filePath);
34
+ },
35
+ append(item) {
36
+ const items = readItems(filePath);
37
+ items.push({
38
+ rawSource: item.rawSource,
39
+ speakableText: item.speakableText,
40
+ sourceApp: item.sourceApp,
41
+ threadId: item.threadId,
42
+ threadLabel: item.threadLabel,
43
+ sessionName: item.sessionName,
44
+ spooledAt: now()
45
+ });
46
+ writeItems(filePath, items);
47
+ return items.length;
48
+ },
49
+ clear() {
50
+ writeItems(filePath, []);
51
+ },
52
+ drain(enqueue) {
53
+ const items = readItems(filePath);
54
+ if (items.length === 0) return { flushed: 0, remaining: 0 };
55
+
56
+ let flushed = 0;
57
+ for (const item of items) {
58
+ try {
59
+ enqueue({
60
+ rawSource: item.rawSource,
61
+ speakableText: item.speakableText,
62
+ sourceApp: item.sourceApp,
63
+ threadId: item.threadId,
64
+ threadLabel: item.threadLabel,
65
+ sessionName: item.sessionName
66
+ });
67
+ flushed += 1;
68
+ } catch {
69
+ break;
70
+ }
71
+ }
72
+
73
+ const remaining = items.slice(flushed);
74
+ writeItems(filePath, remaining);
75
+ return { flushed, remaining: remaining.length };
76
+ }
77
+ };
78
+ }
79
+
80
+ module.exports = { createSpoolStore, defaultSpoolFile };