@shigureni/minion 0.1.0 → 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 (58) hide show
  1. package/README.md +79 -33
  2. package/cli/src/app.ts +2 -0
  3. package/cli/src/factory.ts +8 -1
  4. package/cli/src/index.ts +15 -2
  5. package/cli/src/lib/body-validator.ts +13 -0
  6. package/cli/src/lib/format.ts +36 -0
  7. package/cli/src/router.ts +2 -1
  8. package/cli/src/routes/config/get/[key]/route.ts +7 -7
  9. package/cli/src/routes/config/list/route.ts +4 -5
  10. package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
  11. package/cli/src/routes/dev/route.ts +12 -7
  12. package/cli/src/routes/dex/route.ts +66 -0
  13. package/cli/src/routes/kill/route.ts +7 -5
  14. package/cli/src/routes/not-found.ts +1 -1
  15. package/cli/src/routes/reboot/route.ts +12 -7
  16. package/cli/src/routes/start/route.ts +7 -6
  17. package/cli/src/routes/status/route.ts +5 -5
  18. package/lib/engine/app/app-paths.ts +46 -0
  19. package/lib/engine/app/app-runner.ts +235 -0
  20. package/lib/engine/app/source-hash.ts +34 -0
  21. package/lib/engine/collection/achievements.ts +143 -0
  22. package/lib/engine/collection/collection-store.ts +43 -0
  23. package/lib/engine/collection/collection-tracker.ts +97 -0
  24. package/lib/engine/collection/moon.ts +26 -0
  25. package/lib/engine/collection/species.ts +275 -0
  26. package/lib/engine/config/config-store.ts +36 -0
  27. package/lib/engine/errors.ts +20 -0
  28. package/lib/engine/fs/file-system.ts +31 -0
  29. package/lib/engine/fs/json-file-store.ts +57 -0
  30. package/lib/engine/fs/memory-file-system.ts +106 -0
  31. package/lib/engine/fs/node-file-system.ts +87 -0
  32. package/lib/engine/gateway/gateway-daemon.ts +14 -0
  33. package/lib/engine/gateway/gateway-routes.ts +17 -0
  34. package/lib/engine/gateway/gateway-server.ts +126 -0
  35. package/lib/engine/gateway/pet-behavior.ts +164 -0
  36. package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
  37. package/lib/engine/gateway/sessions.ts +127 -0
  38. package/lib/engine/process/memory-process-runner.ts +67 -0
  39. package/lib/engine/process/node-process-runner.ts +51 -0
  40. package/lib/engine/process/process-runner.ts +21 -0
  41. package/lib/engine/random/memory-random-source.ts +22 -0
  42. package/lib/engine/random/node-random-source.ts +7 -0
  43. package/lib/engine/random/random-source.ts +9 -0
  44. package/lib/engine/stats/date-window.ts +31 -0
  45. package/lib/engine/stats/session-stats-tracker.ts +125 -0
  46. package/lib/engine/stats/stats-collector.ts +100 -0
  47. package/lib/engine/stats/stats-snapshot.ts +31 -0
  48. package/lib/engine/stats/token-usage-tracker.ts +170 -0
  49. package/lib/engine/time/clock.ts +11 -0
  50. package/lib/engine/time/memory-clock.ts +26 -0
  51. package/lib/engine/time/node-clock.ts +7 -0
  52. package/lib/index.ts +65 -0
  53. package/lib/minion.ts +194 -0
  54. package/package.json +9 -1
  55. package/swift/Sources/open-minion/open_minion.swift +5 -1
  56. package/tsconfig.json +3 -2
  57. package/cli/src/gateway.ts +0 -204
  58. package/cli/src/lib/process.ts +0 -204
@@ -0,0 +1,275 @@
1
+ import { isFullMoon, isNewMoon } from "@lib/engine/collection/moon"
2
+ import type { StatsSnapshot } from "@lib/engine/stats/stats-snapshot"
3
+
4
+ export type MinionRarity = "common" | "rare"
5
+
6
+ export type MinionSpecies = {
7
+ id: string
8
+ name: string
9
+ rarity: MinionRarity
10
+ description: string
11
+ condition: (stats: StatsSnapshot) => boolean
12
+ /**
13
+ * Opaque reference to this species' art (a sprite path, bundle key, URL —
14
+ * whatever scheme the host app's renderer uses). This library never reads
15
+ * or interprets it; it just carries it through `dex()` for a host to
16
+ * consume. Left `undefined` in the built-in catalog — pass your own
17
+ * catalog (see `MinionCollectionTracker`'s `species` prop) to fill it in.
18
+ */
19
+ asset?: string
20
+ }
21
+
22
+ /**
23
+ * The built-in minion species catalog. `resolveSpecies` picks the first
24
+ * entry whose condition matches, so order is priority: rare/specific
25
+ * conditions are listed first, and the five time-of-day commons — which
26
+ * together cover every hour — sit last as the guaranteed fallback.
27
+ *
28
+ * This is only the default; pass a custom `species` array to
29
+ * `MinionCollectionTracker` (or `Minion`) to replace it entirely — e.g. to
30
+ * add your own species, retheme the built-ins, or attach real `asset`
31
+ * references once art exists.
32
+ */
33
+ export const DEFAULT_MINION_SPECIES: MinionSpecies[] = [
34
+ {
35
+ id: "zorome",
36
+ name: "ゾロめミニオン",
37
+ rarity: "rare",
38
+ description: "時計が11:11か22:22を指す瞬間だけ現れる。",
39
+ condition: (s) => {
40
+ const minute = s.now.getMinutes()
41
+ return (s.hour === 11 && minute === 11) || (s.hour === 22 && minute === 22)
42
+ },
43
+ },
44
+ {
45
+ id: "full-moon",
46
+ name: "まんげつミニオン",
47
+ rarity: "rare",
48
+ description: "満月の夜(20時〜5時)に現れる。",
49
+ condition: (s) =>
50
+ isFullMoon(s.now) && (s.timeBucket === "night" || s.timeBucket === "lateNight"),
51
+ },
52
+ {
53
+ id: "new-moon",
54
+ name: "しんげつミニオン",
55
+ rarity: "rare",
56
+ description: "新月の深夜、月のない暗い夜に現れる。",
57
+ condition: (s) => isNewMoon(s.now) && s.timeBucket === "lateNight",
58
+ },
59
+ {
60
+ id: "mega-swarm",
61
+ name: "だいぐんせいミニオン",
62
+ rarity: "rare",
63
+ description: "同時に10以上のセッションが動く修羅場に現れる。",
64
+ condition: (s) => s.currentConcurrentSessions >= 10,
65
+ },
66
+ {
67
+ id: "swarm",
68
+ name: "ぐんせいミニオン",
69
+ rarity: "rare",
70
+ description: "同時に5つ以上のセッションが動いているときだけ現れる。",
71
+ condition: (s) => s.currentConcurrentSessions >= 5,
72
+ },
73
+ {
74
+ id: "overdrive",
75
+ name: "オーバードライブミニオン",
76
+ rarity: "rare",
77
+ description: "同時に3つ以上のセッションが動いているときに現れる。",
78
+ condition: (s) => s.currentConcurrentSessions >= 3,
79
+ },
80
+ {
81
+ id: "insomniac",
82
+ name: "ふみんミニオン",
83
+ rarity: "rare",
84
+ description: "深夜(0時〜5時)にセッションを動かしていると現れる。",
85
+ condition: (s) => s.timeBucket === "lateNight" && s.currentConcurrentSessions >= 1,
86
+ },
87
+ {
88
+ id: "morning-active",
89
+ name: "あさかつミニオン",
90
+ rarity: "rare",
91
+ description: "朝(5時〜10時)にセッションを動かしていると現れる。",
92
+ condition: (s) => s.timeBucket === "morning" && s.currentConcurrentSessions >= 1,
93
+ },
94
+ {
95
+ id: "lunch-break",
96
+ name: "おひるミニオン",
97
+ rarity: "rare",
98
+ description: "お昼(12時台)にセッションを動かしていると現れる。",
99
+ condition: (s) => s.hour === 12 && s.currentConcurrentSessions >= 1,
100
+ },
101
+ {
102
+ id: "twins",
103
+ name: "ふたごミニオン",
104
+ rarity: "rare",
105
+ description: "同時に2つのセッションが動いていると現れる。",
106
+ condition: (s) => s.currentConcurrentSessions >= 2,
107
+ },
108
+ {
109
+ id: "legend",
110
+ name: "でんせつミニオン",
111
+ rarity: "rare",
112
+ description: "累計1億トークンを消費したでんせつのもとに現れる。",
113
+ condition: (s) => s.tokensTotal >= 100_000_000,
114
+ },
115
+ {
116
+ id: "torrent",
117
+ name: "トレントミニオン",
118
+ rarity: "rare",
119
+ description: "1週間で500万トークンを消費すると現れる。",
120
+ condition: (s) => s.tokensThisWeek >= 5_000_000,
121
+ },
122
+ {
123
+ id: "golden",
124
+ name: "こがねミニオン",
125
+ rarity: "rare",
126
+ description: "累計1000万トークンを消費した猛者に現れる。",
127
+ condition: (s) => s.tokensTotal >= 10_000_000,
128
+ },
129
+ {
130
+ id: "big-spender",
131
+ name: "だいしょうひミニオン",
132
+ rarity: "rare",
133
+ description: "1日で100万トークンを消費すると現れる。",
134
+ condition: (s) => s.tokensToday >= 1_000_000,
135
+ },
136
+ {
137
+ id: "glutton",
138
+ name: "おおぐいミニオン",
139
+ rarity: "rare",
140
+ description: "1日で30万トークンを消費すると現れる。",
141
+ condition: (s) => s.tokensToday >= 300_000,
142
+ },
143
+ {
144
+ id: "perfect-attendance",
145
+ name: "かいきんミニオン",
146
+ rarity: "rare",
147
+ description: "30日連続でセッションを実行していると現れる。",
148
+ condition: (s) => s.currentStreakDays >= 30,
149
+ },
150
+ {
151
+ id: "marathon",
152
+ name: "マラソンミニオン",
153
+ rarity: "rare",
154
+ description: "7日連続でセッションを実行していると現れる。",
155
+ condition: (s) => s.currentStreakDays >= 7,
156
+ },
157
+ {
158
+ id: "three-day-streak",
159
+ name: "みっかミニオン",
160
+ rarity: "rare",
161
+ description: "3日連続でセッションを実行していると現れる。みっかぼうず卒業。",
162
+ condition: (s) => s.currentStreakDays >= 3,
163
+ },
164
+ {
165
+ id: "month-end",
166
+ name: "しめきりミニオン",
167
+ rarity: "rare",
168
+ description: "月末の日に現れる。しめきりは大丈夫?",
169
+ condition: (s) =>
170
+ new Date(s.now.getFullYear(), s.now.getMonth(), s.now.getDate() + 1).getDate() === 1,
171
+ },
172
+ {
173
+ id: "friday-night",
174
+ name: "きんようミニオン",
175
+ rarity: "rare",
176
+ description: "金曜の夜(20時〜24時)にセッションを動かしていると現れる。",
177
+ condition: (s) =>
178
+ s.now.getDay() === 5 && s.timeBucket === "night" && s.currentConcurrentSessions >= 1,
179
+ },
180
+ {
181
+ id: "sunday-worker",
182
+ name: "にちようミニオン",
183
+ rarity: "rare",
184
+ description: "日曜にセッションを動かしていると現れる。",
185
+ condition: (s) => s.now.getDay() === 0 && s.currentConcurrentSessions >= 1,
186
+ },
187
+ {
188
+ id: "devoted",
189
+ name: "いちずミニオン",
190
+ rarity: "rare",
191
+ description: "累計50セッションを超えてもプロジェクトがひとつだけだと現れる。",
192
+ condition: (s) => s.uniqueProjectsSeen === 1 && s.totalSessionsSeen >= 50,
193
+ },
194
+ {
195
+ id: "wanderer",
196
+ name: "ほうろうミニオン",
197
+ rarity: "rare",
198
+ description: "10個以上の異なるプロジェクトを渡り歩くと現れる。",
199
+ condition: (s) => s.uniqueProjectsSeen >= 10,
200
+ },
201
+ {
202
+ id: "juggler",
203
+ name: "かけもちミニオン",
204
+ rarity: "rare",
205
+ description: "3つ以上のプロジェクトをかけもちすると現れる。",
206
+ condition: (s) => s.uniqueProjectsSeen >= 3,
207
+ },
208
+ {
209
+ id: "veteran",
210
+ name: "ベテランミニオン",
211
+ rarity: "rare",
212
+ description: "累計100セッションを超えたころに現れる。",
213
+ condition: (s) => s.totalSessionsSeen >= 100,
214
+ },
215
+ {
216
+ id: "rookie",
217
+ name: "ひよこミニオン",
218
+ rarity: "rare",
219
+ description: "累計10セッションを実行したかけだしに現れる。",
220
+ condition: (s) => s.totalSessionsSeen >= 10,
221
+ },
222
+ {
223
+ id: "late-night",
224
+ name: "しんやミニオン",
225
+ rarity: "common",
226
+ description: "深夜(0時〜5時)に現れる。",
227
+ condition: (s) => s.timeBucket === "lateNight",
228
+ },
229
+ {
230
+ id: "morning",
231
+ name: "あさミニオン",
232
+ rarity: "common",
233
+ description: "朝(5時〜10時)に現れる。",
234
+ condition: (s) => s.timeBucket === "morning",
235
+ },
236
+ {
237
+ id: "day",
238
+ name: "ひなたミニオン",
239
+ rarity: "common",
240
+ description: "日中(10時〜17時)に現れる。",
241
+ condition: (s) => s.timeBucket === "day",
242
+ },
243
+ {
244
+ id: "evening",
245
+ name: "ゆうやけミニオン",
246
+ rarity: "common",
247
+ description: "夕方(17時〜20時)に現れる。",
248
+ condition: (s) => s.timeBucket === "evening",
249
+ },
250
+ {
251
+ id: "night",
252
+ name: "よふかしミニオン",
253
+ rarity: "common",
254
+ description: "夜(20時〜24時)に現れる。",
255
+ condition: (s) => s.timeBucket === "night",
256
+ },
257
+ ]
258
+
259
+ /**
260
+ * The species that manifests right now, per `stats`. Checks `catalog` in
261
+ * order and returns the first match — defaults to `DEFAULT_MINION_SPECIES`,
262
+ * whose time-of-day commons cover every hour so it always resolves. A custom
263
+ * catalog must include an unconditional (or otherwise fully-covering)
264
+ * fallback entry, or this returns an Error when nothing matches.
265
+ */
266
+ export function resolveSpecies(
267
+ stats: StatsSnapshot,
268
+ catalog: MinionSpecies[] = DEFAULT_MINION_SPECIES,
269
+ ): MinionSpecies | Error {
270
+ const found = catalog.find((species) => species.condition(stats))
271
+ if (!found) {
272
+ return new Error("no minion species matched — the catalog needs a fully-covering fallback")
273
+ }
274
+ return found
275
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod"
2
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
3
+ import { JsonFileStore } from "@lib/engine/fs/json-file-store"
4
+
5
+ type Props = {
6
+ fs: MinionFileSystem
7
+ path: string
8
+ }
9
+
10
+ const schema = z.record(z.string(), z.string())
11
+
12
+ const EMPTY: Record<string, string> = {}
13
+
14
+ /** Reads/writes the flat string-keyed `config.json` (`minion config get/set/list`). */
15
+ export class MinionConfigStore {
16
+ private readonly store: JsonFileStore<Record<string, string>>
17
+
18
+ constructor(props: Props) {
19
+ this.store = new JsonFileStore({ fs: props.fs, path: props.path, schema, defaultValue: EMPTY })
20
+ }
21
+
22
+ list(): Record<string, string> {
23
+ return this.store.read()
24
+ }
25
+
26
+ /** Returns `undefined` when the key is unset — distinguishable from an empty-string value. */
27
+ get(key: string): string | undefined {
28
+ return this.list()[key]
29
+ }
30
+
31
+ set(key: string, value: string): Error | null {
32
+ const config = this.list()
33
+ config[key] = value
34
+ return this.store.write(config)
35
+ }
36
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Error-as-value helpers. The library never throws across its public surface —
3
+ * fallible operations return `T | Error`, checked with `instanceof Error`.
4
+ * These helpers convert the throwing world (node:fs, Bun, JSON.parse) into
5
+ * that convention at the boundary.
6
+ */
7
+
8
+ /** Normalizes a caught value into an Error without losing the original message. */
9
+ export function toError(thrown: unknown): Error {
10
+ return thrown instanceof Error ? thrown : new Error(String(thrown))
11
+ }
12
+
13
+ /** `JSON.parse` without the throw. JSON can never encode an `Error` instance, so `instanceof Error` cleanly separates failure. */
14
+ export function safeJsonParse(text: string): unknown {
15
+ try {
16
+ return JSON.parse(text)
17
+ } catch (thrown) {
18
+ return toError(thrown)
19
+ }
20
+ }
@@ -0,0 +1,31 @@
1
+ export type MinionFileStat = {
2
+ mtimeMs: number
3
+ }
4
+
5
+ /**
6
+ * Filesystem boundary used everywhere minion reads or writes.
7
+ * Default is NodeMinionFileSystem (real `node:fs`); MemoryMinionFileSystem
8
+ * provides a sandbox for tests and embedded use.
9
+ *
10
+ * Fallible operations return `T | Error` (checked with `instanceof Error`)
11
+ * instead of throwing — implementations must catch their runtime's exceptions
12
+ * at this boundary.
13
+ */
14
+ export abstract class MinionFileSystem {
15
+ abstract existsSync(path: string): boolean
16
+ abstract readFileSync(path: string): string | Error
17
+ abstract writeFileSync(path: string, data: string): Error | null
18
+ abstract mkdirSync(path: string, options?: { recursive?: boolean }): Error | null
19
+ abstract rmSync(path: string, options?: { force?: boolean }): Error | null
20
+ abstract readdirSync(path: string): string[] | Error
21
+ /** Recursively list files under `path`, as `/`-joined paths relative to `path`. */
22
+ abstract readdirRecursiveSync(path: string): string[] | Error
23
+ /**
24
+ * Atomically create `path` only if it does not already exist (O_EXCL).
25
+ * Returns `true` if this call created it, `false` if it already existed.
26
+ * Used for the start-lock / pid-file singleton guard.
27
+ */
28
+ abstract createExclusiveSync(path: string): boolean
29
+ /** Used to detect whether a file changed since it was last scanned (e.g. transcript token-usage scanning). */
30
+ abstract statSync(path: string): MinionFileStat | Error
31
+ }
@@ -0,0 +1,57 @@
1
+ import { dirname } from "node:path"
2
+ import type { z } from "zod"
3
+ import { safeJsonParse } from "@lib/engine/errors"
4
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
5
+
6
+ type Props<T> = {
7
+ fs: MinionFileSystem
8
+ path: string
9
+ /**
10
+ * Runtime shape guard for what comes back off disk — `JSON.parse` alone
11
+ * would let a hand-edited or version-skewed file flow through as `T` and
12
+ * crash later. Give evolving schemas per-field `.catch()` defaults so a
13
+ * file written by an older version backfills instead of falling back wholesale.
14
+ */
15
+ schema: z.ZodType<T>
16
+ defaultValue: T
17
+ }
18
+
19
+ /**
20
+ * Reads/writes a single JSON value at `path`, creating parent directories as
21
+ * needed. Falls back to a fresh clone of `defaultValue` when the file is
22
+ * missing, unreadable, corrupt, or fails `schema` validation. Shared by every
23
+ * `~/.minion/*.json` store (config, session stats, token usage, collection)
24
+ * to avoid re-deriving the same read/parse/validate/write boilerplate in each one.
25
+ */
26
+ export class JsonFileStore<T> {
27
+ private readonly fs: MinionFileSystem
28
+ private readonly path: string
29
+ private readonly schema: z.ZodType<T>
30
+ private readonly defaultValue: T
31
+
32
+ constructor(props: Props<T>) {
33
+ this.fs = props.fs
34
+ this.path = props.path
35
+ this.schema = props.schema
36
+ this.defaultValue = props.defaultValue
37
+ }
38
+
39
+ read(): T {
40
+ if (!this.fs.existsSync(this.path)) return structuredClone(this.defaultValue)
41
+
42
+ const content = this.fs.readFileSync(this.path)
43
+ if (content instanceof Error) return structuredClone(this.defaultValue)
44
+
45
+ const json = safeJsonParse(content)
46
+ if (json instanceof Error) return structuredClone(this.defaultValue)
47
+
48
+ const result = this.schema.safeParse(json)
49
+ return result.success ? result.data : structuredClone(this.defaultValue)
50
+ }
51
+
52
+ write(value: T): Error | null {
53
+ const mkdirError = this.fs.mkdirSync(dirname(this.path), { recursive: true })
54
+ if (mkdirError) return mkdirError
55
+ return this.fs.writeFileSync(this.path, JSON.stringify(value, null, 2))
56
+ }
57
+ }
@@ -0,0 +1,106 @@
1
+ import { type MinionFileStat, MinionFileSystem } from "@lib/engine/fs/file-system"
2
+
3
+ type Props = {
4
+ dirs?: string[]
5
+ files?: Record<string, string>
6
+ mtimes?: Record<string, number>
7
+ /** Clock used to stamp mtimes on write, when a file's mtime isn't set explicitly via `setMtime`. Defaults to `Date.now`. */
8
+ now?: () => number
9
+ }
10
+
11
+ export class MemoryMinionFileSystem extends MinionFileSystem {
12
+ private readonly dirs: Set<string>
13
+ private readonly files: Map<string, string>
14
+ private readonly mtimes: Map<string, number>
15
+ private readonly now: () => number
16
+
17
+ constructor(props: Props = {}) {
18
+ super()
19
+ this.dirs = new Set(props.dirs ?? [])
20
+ this.files = new Map(Object.entries(props.files ?? {}))
21
+ this.mtimes = new Map(Object.entries(props.mtimes ?? {}))
22
+ this.now = props.now ?? (() => Date.now())
23
+ }
24
+
25
+ existsSync(path: string): boolean {
26
+ return this.dirs.has(path) || this.files.has(path)
27
+ }
28
+
29
+ readFileSync(path: string): string | Error {
30
+ const data = this.files.get(path)
31
+ if (data === undefined) return new Error(`not found: ${path}`)
32
+ return data
33
+ }
34
+
35
+ writeFileSync(path: string, data: string): Error | null {
36
+ this.files.set(path, data)
37
+ this.touch(path)
38
+ return null
39
+ }
40
+
41
+ mkdirSync(path: string, options?: { recursive?: boolean }): Error | null {
42
+ void options
43
+ this.dirs.add(path)
44
+ return null
45
+ }
46
+
47
+ rmSync(path: string, options?: { force?: boolean }): Error | null {
48
+ if (!options?.force && !this.existsSync(path)) {
49
+ return new Error(`not found: ${path}`)
50
+ }
51
+ this.files.delete(path)
52
+ this.dirs.delete(path)
53
+ this.mtimes.delete(path)
54
+ return null
55
+ }
56
+
57
+ readdirSync(path: string): string[] | Error {
58
+ const prefix = path.endsWith("/") ? path : `${path}/`
59
+ const names = new Set<string>()
60
+
61
+ for (const file of this.files.keys()) {
62
+ if (!file.startsWith(prefix)) continue
63
+ const rest = file.slice(prefix.length)
64
+ const [first] = rest.split("/")
65
+ if (first) names.add(first)
66
+ }
67
+
68
+ return Array.from(names)
69
+ }
70
+
71
+ /** Returns file paths only (no directory entries), unlike node:fs's recursive readdirSync. */
72
+ readdirRecursiveSync(path: string): string[] | Error {
73
+ const prefix = path.endsWith("/") ? path : `${path}/`
74
+ const names: string[] = []
75
+
76
+ for (const file of this.files.keys()) {
77
+ if (!file.startsWith(prefix)) continue
78
+ names.push(file.slice(prefix.length))
79
+ }
80
+
81
+ return names
82
+ }
83
+
84
+ createExclusiveSync(path: string): boolean {
85
+ if (this.files.has(path)) return false
86
+ this.files.set(path, "")
87
+ this.touch(path)
88
+ return true
89
+ }
90
+
91
+ statSync(path: string): MinionFileStat | Error {
92
+ if (!this.files.has(path)) return new Error(`not found: ${path}`)
93
+ const mtimeMs = this.mtimes.get(path)
94
+ if (mtimeMs === undefined) this.touch(path)
95
+ return { mtimeMs: this.mtimes.get(path) ?? this.now() }
96
+ }
97
+
98
+ /** Sets a file's mtime explicitly — useful for asserting "unchanged since last scan" behavior in tests. */
99
+ setMtime(path: string, mtimeMs: number): void {
100
+ this.mtimes.set(path, mtimeMs)
101
+ }
102
+
103
+ private touch(path: string): void {
104
+ this.mtimes.set(path, this.now())
105
+ }
106
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs"
12
+ import { toError } from "@lib/engine/errors"
13
+ import { type MinionFileStat, MinionFileSystem } from "@lib/engine/fs/file-system"
14
+
15
+ export class NodeMinionFileSystem extends MinionFileSystem {
16
+ existsSync(path: string): boolean {
17
+ return existsSync(path)
18
+ }
19
+
20
+ readFileSync(path: string): string | Error {
21
+ try {
22
+ return readFileSync(path, "utf8")
23
+ } catch (thrown) {
24
+ return toError(thrown)
25
+ }
26
+ }
27
+
28
+ writeFileSync(path: string, data: string): Error | null {
29
+ try {
30
+ writeFileSync(path, data)
31
+ return null
32
+ } catch (thrown) {
33
+ return toError(thrown)
34
+ }
35
+ }
36
+
37
+ mkdirSync(path: string, options?: { recursive?: boolean }): Error | null {
38
+ try {
39
+ mkdirSync(path, { recursive: options?.recursive ?? false })
40
+ return null
41
+ } catch (thrown) {
42
+ return toError(thrown)
43
+ }
44
+ }
45
+
46
+ rmSync(path: string, options?: { force?: boolean }): Error | null {
47
+ try {
48
+ rmSync(path, { force: options?.force ?? false })
49
+ return null
50
+ } catch (thrown) {
51
+ return toError(thrown)
52
+ }
53
+ }
54
+
55
+ readdirSync(path: string): string[] | Error {
56
+ try {
57
+ return readdirSync(path)
58
+ } catch (thrown) {
59
+ return toError(thrown)
60
+ }
61
+ }
62
+
63
+ readdirRecursiveSync(path: string): string[] | Error {
64
+ try {
65
+ return readdirSync(path, { recursive: true }) as string[]
66
+ } catch (thrown) {
67
+ return toError(thrown)
68
+ }
69
+ }
70
+
71
+ createExclusiveSync(path: string): boolean {
72
+ try {
73
+ closeSync(openSync(path, "wx"))
74
+ return true
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+
80
+ statSync(path: string): MinionFileStat | Error {
81
+ try {
82
+ return { mtimeMs: statSync(path).mtimeMs }
83
+ } catch (thrown) {
84
+ return toError(thrown)
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bun
2
+ import { Minion } from "@lib/minion"
3
+
4
+ const minion = new Minion()
5
+ const port = Number(process.env.MINION_GATEWAY_PORT) || undefined
6
+
7
+ const handle = minion.gatewayServer({ port }).start()
8
+
9
+ if (handle instanceof Error) {
10
+ console.error(`gateway failed to start: ${handle.message}`)
11
+ process.exit(1)
12
+ }
13
+
14
+ console.log(`gateway listening on :${handle.port}`)
@@ -0,0 +1,17 @@
1
+ import { Hono } from "hono"
2
+ import type { PetBehaviorEngine } from "@lib/engine/gateway/pet-behavior"
3
+
4
+ /**
5
+ * GET /sessions — the current pet snapshot as JSON. Kept as a plain Hono app
6
+ * (rather than inlined into `Bun.serve`'s fetch handler) so it can be
7
+ * exercised with `.request()` in tests without a real Bun server. The `/ws`
8
+ * upgrade path lives in gateway-server.ts since it needs the raw Bun server
9
+ * instance to call `server.upgrade(req)`.
10
+ */
11
+ export function buildGatewayRoutes(engine: PetBehaviorEngine) {
12
+ const app = new Hono()
13
+ app.get("/sessions", (c) => c.json({ sessions: engine.snapshot() }))
14
+ return app
15
+ }
16
+
17
+ export type GatewayRoutesApp = ReturnType<typeof buildGatewayRoutes>