@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,126 @@
1
+ import type { Hono } from "hono"
2
+ import { toError } from "@lib/engine/errors"
3
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
4
+ import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
5
+ import type { MinionClock } from "@lib/engine/time/clock"
6
+ import type { MinionRandomSource } from "@lib/engine/random/random-source"
7
+ import { buildGatewayRoutes } from "@lib/engine/gateway/gateway-routes"
8
+ import { PetBehaviorEngine } from "@lib/engine/gateway/pet-behavior"
9
+ import { readActiveSessions } from "@lib/engine/gateway/sessions"
10
+
11
+ const DEFAULT_PORT = 4756
12
+ const DEFAULT_TICK_MS = 250
13
+
14
+ type Props = {
15
+ fs: MinionFileSystem
16
+ process: MinionProcessRunner
17
+ clock: MinionClock
18
+ random: MinionRandomSource
19
+ sessionsDir: string
20
+ projectsDir: string
21
+ port?: number
22
+ tickMs?: number
23
+ }
24
+
25
+ export type MinionGatewayHandle = {
26
+ port: number
27
+ stop(): void
28
+ }
29
+
30
+ /**
31
+ * In-process HTTP + WebSocket server that watches the sessions directory and
32
+ * broadcasts each pet's animation state. `.routes` (the `/sessions` JSON
33
+ * endpoint) is a plain Hono app testable via `.request()`; `.start()` wraps
34
+ * it plus the `/ws` upgrade in a real `Bun.serve` — like the IO boundaries
35
+ * this class composes, that call is a genuine runtime dependency rather than
36
+ * something behind an interface, so pass `port: 0` to bind an ephemeral port
37
+ * when a test does need a live server.
38
+ */
39
+ export class MinionGatewayServer {
40
+ readonly routes: Hono
41
+
42
+ private readonly fs: MinionFileSystem
43
+ private readonly process: MinionProcessRunner
44
+ private readonly clock: MinionClock
45
+ private readonly sessionsDir: string
46
+ private readonly projectsDir: string
47
+ private readonly port: number
48
+ private readonly tickMs: number
49
+ private readonly engine: PetBehaviorEngine
50
+
51
+ constructor(props: Props) {
52
+ this.fs = props.fs
53
+ this.process = props.process
54
+ this.clock = props.clock
55
+ this.sessionsDir = props.sessionsDir
56
+ this.projectsDir = props.projectsDir
57
+ this.port = props.port ?? DEFAULT_PORT
58
+ this.tickMs = props.tickMs ?? DEFAULT_TICK_MS
59
+ this.engine = new PetBehaviorEngine({ random: props.random })
60
+ this.routes = buildGatewayRoutes(this.engine)
61
+ }
62
+
63
+ /** Binds the real server. Returns an Error (instead of throwing) when the port is taken or binding fails. */
64
+ start(): MinionGatewayHandle | Error {
65
+ const engine = this.engine
66
+ const routes = this.routes
67
+ const clients = new Set<{ send: (data: string) => void }>()
68
+
69
+ const broadcast = (): void => {
70
+ const payload = JSON.stringify({ sessions: engine.snapshot() })
71
+ for (const ws of clients) ws.send(payload)
72
+ }
73
+
74
+ const tick = (): void => {
75
+ const now = this.clock.millis()
76
+ const activeSessions = readActiveSessions({
77
+ fs: this.fs,
78
+ process: this.process,
79
+ clock: this.clock,
80
+ sessionsDir: this.sessionsDir,
81
+ projectsDir: this.projectsDir,
82
+ })
83
+ if (engine.tick(now, activeSessions)) broadcast()
84
+ }
85
+
86
+ const interval = setInterval(tick, this.tickMs)
87
+ tick()
88
+
89
+ try {
90
+ const server = Bun.serve({
91
+ port: this.port,
92
+ fetch(req, srv) {
93
+ const url = new URL(req.url)
94
+ if (url.pathname === "/ws") {
95
+ if (srv.upgrade(req)) return undefined
96
+ return new Response("upgrade failed", { status: 400 })
97
+ }
98
+ return routes.fetch(req)
99
+ },
100
+ websocket: {
101
+ open(ws) {
102
+ clients.add(ws)
103
+ ws.send(JSON.stringify({ sessions: engine.snapshot() }))
104
+ },
105
+ close(ws) {
106
+ clients.delete(ws)
107
+ },
108
+ message() {
109
+ // クライアントからのメッセージは使わない
110
+ },
111
+ },
112
+ })
113
+
114
+ return {
115
+ port: server.port ?? this.port,
116
+ stop: () => {
117
+ clearInterval(interval)
118
+ void server.stop(true)
119
+ },
120
+ }
121
+ } catch (thrown) {
122
+ clearInterval(interval)
123
+ return toError(thrown)
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,164 @@
1
+ import type { MinionRandomSource } from "@lib/engine/random/random-source"
2
+ import type { SessionInfo } from "@lib/engine/gateway/sessions"
3
+
4
+ export const IDLE_CLIP = 0
5
+
6
+ // Swift側 PetView.clips と同じ並び順・重み(合計100)。座標や速度はSwiftが持つ
7
+ // clip定義とスクリーン形状に依存するため、ここでは「どの行動を・どれだけ続けるか」だけを決める。
8
+ export const ACTIONS: { durationMs: [number, number]; weight: number }[] = [
9
+ { weight: 14, durationMs: [3000, 6000] }, // 0 待機
10
+ { weight: 10, durationMs: [3000, 6000] }, // 1 歩く
11
+ { weight: 28, durationMs: [4000, 7000] }, // 2 走る
12
+ { weight: 3, durationMs: [1000, 2000] }, // 3 ジャンプ1
13
+ { weight: 3, durationMs: [1000, 2000] }, // 4 ジャンプ2
14
+ { weight: 3, durationMs: [1000, 2000] }, // 5 ジャンプ3
15
+ { weight: 3, durationMs: [1000, 2000] }, // 6 ジャンプ4
16
+ { weight: 8, durationMs: [4000, 7000] }, // 7 座る1
17
+ { weight: 8, durationMs: [4000, 7000] }, // 8 座る2
18
+ { weight: 8, durationMs: [4000, 7000] }, // 9 座る3
19
+ { weight: 8, durationMs: [4000, 7000] }, // 10 座る4
20
+ { weight: 2, durationMs: [2000, 3000] }, // 11 食べる1
21
+ { weight: 2, durationMs: [2000, 3000] }, // 12 食べる2
22
+ ]
23
+
24
+ // 稼働していないセッション用の「静かな」行動だけの部分集合(clipIndexはACTIONS/Swiftの
25
+ // 並びを指す)。移動・ジャンプ・食事は含めず、待機と座り姿勢をゆっくり切り替える。
26
+ export const SLEEPING_ACTIONS: {
27
+ clipIndex: number
28
+ durationMs: [number, number]
29
+ weight: number
30
+ }[] = [
31
+ { clipIndex: 0, weight: 40, durationMs: [6000, 12000] }, // 待機
32
+ { clipIndex: 7, weight: 15, durationMs: [6000, 12000] }, // 座る1
33
+ { clipIndex: 8, weight: 15, durationMs: [6000, 12000] }, // 座る2
34
+ { clipIndex: 9, weight: 15, durationMs: [6000, 12000] }, // 座る3
35
+ { clipIndex: 10, weight: 15, durationMs: [6000, 12000] }, // 座る4
36
+ ]
37
+
38
+ export type PetAction = { clipIndex: number; durationMs: number }
39
+
40
+ export function pickAction(random: MinionRandomSource): PetAction {
41
+ const roll = random.next() * 100
42
+ let cumulative = 0
43
+ let chosen = IDLE_CLIP
44
+
45
+ for (let i = 0; i < ACTIONS.length; i++) {
46
+ const action = ACTIONS[i]
47
+ if (!action) continue
48
+ cumulative += action.weight
49
+ if (roll < cumulative) {
50
+ chosen = i
51
+ break
52
+ }
53
+ }
54
+
55
+ const chosenAction = ACTIONS[chosen]
56
+ const [min, max] = chosenAction?.durationMs ?? [0, 0]
57
+ return { clipIndex: chosen, durationMs: min + random.next() * (max - min) }
58
+ }
59
+
60
+ export function pickSleepingAction(random: MinionRandomSource): PetAction {
61
+ const roll = random.next() * 100
62
+ let cumulative = 0
63
+ let chosen = SLEEPING_ACTIONS[0]
64
+
65
+ for (const action of SLEEPING_ACTIONS) {
66
+ cumulative += action.weight
67
+ if (roll < cumulative) {
68
+ chosen = action
69
+ break
70
+ }
71
+ }
72
+
73
+ const [min, max] = chosen?.durationMs ?? [0, 0]
74
+ return {
75
+ clipIndex: chosen?.clipIndex ?? IDLE_CLIP,
76
+ durationMs: min + random.next() * (max - min),
77
+ }
78
+ }
79
+
80
+ export type PetBehavior = {
81
+ running: boolean
82
+ name: string
83
+ clipIndex: number
84
+ actionEndsAt: number
85
+ }
86
+
87
+ export type PetSnapshotEntry = {
88
+ id: string
89
+ state: "running" | "sleeping"
90
+ clipIndex: number
91
+ name: string
92
+ }
93
+
94
+ /**
95
+ * Pure per-session state machine: given the latest active-session snapshot and
96
+ * the current time, decides each pet's animation clip and how long it plays.
97
+ * Holds no IO — safe to tick from a test with a fake clock and random source.
98
+ */
99
+ export class PetBehaviorEngine {
100
+ private readonly random: MinionRandomSource
101
+ private readonly behaviors = new Map<string, PetBehavior>()
102
+
103
+ constructor(props: { random: MinionRandomSource }) {
104
+ this.random = props.random
105
+ }
106
+
107
+ /** Advances state given `now` and the latest active sessions. Returns whether anything changed. */
108
+ tick(now: number, activeSessions: Map<string, SessionInfo>): boolean {
109
+ let dirty = false
110
+
111
+ for (const id of this.behaviors.keys()) {
112
+ if (!activeSessions.has(id)) {
113
+ this.behaviors.delete(id)
114
+ dirty = true
115
+ }
116
+ }
117
+
118
+ for (const [id, info] of activeSessions) {
119
+ const existing = this.behaviors.get(id)
120
+
121
+ if (!existing) {
122
+ const action = info.running ? pickAction(this.random) : pickSleepingAction(this.random)
123
+ this.behaviors.set(id, {
124
+ running: info.running,
125
+ name: info.name,
126
+ clipIndex: action.clipIndex,
127
+ actionEndsAt: now + action.durationMs,
128
+ })
129
+ dirty = true
130
+ continue
131
+ }
132
+
133
+ if (existing.name !== info.name) {
134
+ existing.name = info.name
135
+ dirty = true
136
+ }
137
+
138
+ if (existing.running !== info.running) {
139
+ existing.running = info.running
140
+ // 稼働が止まったときも静かな姿勢を選び直す(走りの途中コマで固まらないように)。
141
+ const action = info.running ? pickAction(this.random) : pickSleepingAction(this.random)
142
+ existing.clipIndex = action.clipIndex
143
+ existing.actionEndsAt = now + action.durationMs
144
+ dirty = true
145
+ } else if (now >= existing.actionEndsAt) {
146
+ const action = info.running ? pickAction(this.random) : pickSleepingAction(this.random)
147
+ if (action.clipIndex !== existing.clipIndex) dirty = true
148
+ existing.clipIndex = action.clipIndex
149
+ existing.actionEndsAt = now + action.durationMs
150
+ }
151
+ }
152
+
153
+ return dirty
154
+ }
155
+
156
+ snapshot(): PetSnapshotEntry[] {
157
+ return Array.from(this.behaviors.entries()).map(([id, b]) => ({
158
+ id,
159
+ state: b.running ? "running" : "sleeping",
160
+ clipIndex: b.clipIndex,
161
+ name: b.name,
162
+ }))
163
+ }
164
+ }
@@ -0,0 +1,7 @@
1
+ import { fileURLToPath } from "node:url"
2
+
3
+ /** Absolute path to the standalone gateway daemon entry, for spawning as a detached process. */
4
+ export function resolveGatewayDaemonScript(): string {
5
+ // URL.pathname はパスに空白や非ASCII文字があるとパーセントエンコードされたまま壊れる
6
+ return fileURLToPath(new URL("gateway-daemon.ts", import.meta.url))
7
+ }
@@ -0,0 +1,127 @@
1
+ import { join } from "node:path"
2
+ import { z } from "zod"
3
+ import { safeJsonParse } from "@lib/engine/errors"
4
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
5
+ import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
6
+ import type { MinionClock } from "@lib/engine/time/clock"
7
+
8
+ const DEFAULT_STALE_MS = 8 * 60 * 1000
9
+ const DEFAULT_TRANSCRIPT_ACTIVE_MS = 2 * 60 * 1000
10
+
11
+ // One `~/.claude/sessions/*.json` file. `sessionId` and `pid` are required to
12
+ // identify the session at all; everything else degrades gracefully (`.catch()`)
13
+ // since these files are written by Claude Code, not by us. `status` stays
14
+ // `undefined` when absent — claude-desktop sessions never write it, and
15
+ // "no status" (fall back to transcript activity) must stay distinguishable
16
+ // from "status: idle".
17
+ const sessionFileSchema = z.looseObject({
18
+ sessionId: z.string(),
19
+ pid: z.number(),
20
+ status: z.string().optional().catch(undefined),
21
+ name: z.string().catch(""),
22
+ cwd: z.string().optional().catch(undefined),
23
+ updatedAt: z.number().optional().catch(undefined),
24
+ statusUpdatedAt: z.number().optional().catch(undefined),
25
+ startedAt: z.number().optional().catch(undefined),
26
+ })
27
+
28
+ export type SessionInfo = {
29
+ running: boolean
30
+ name: string
31
+ /** The session's working directory, when Claude Code recorded one — used to count distinct projects worked in. */
32
+ cwd?: string
33
+ }
34
+
35
+ type Props = {
36
+ fs: MinionFileSystem
37
+ process: MinionProcessRunner
38
+ clock: MinionClock
39
+ sessionsDir: string
40
+ /** Claude Code's transcript root (`~/.claude/projects`) — the busy signal for sessions whose file has no `status`. */
41
+ projectsDir: string
42
+ /** How long a dead session's last-known status is trusted before it's dropped. Defaults to 8 minutes. */
43
+ staleMs?: number
44
+ /** How recent a transcript write counts as "busy" for statusless sessions. Defaults to 2 minutes. */
45
+ transcriptActiveMs?: number
46
+ }
47
+
48
+ /**
49
+ * Reads every `~/.claude/sessions/*.json` file and resolves each into a
50
+ * running/idle verdict. updatedAt only changes when status flips, so a
51
+ * session that's been busy for a long time can't be judged by elapsed time
52
+ * alone — while its process is alive we trust `status` directly, and only
53
+ * fall back to the `staleMs` grace period once the process has died.
54
+ *
55
+ * claude-desktop sessions write their file once at startup and never include
56
+ * `status` at all, so for those the transcript file's mtime is the busy
57
+ * signal instead: the transcript is appended to continuously while Claude
58
+ * works, so a write within `transcriptActiveMs` means the session is running.
59
+ *
60
+ * An unreadable directory reads as "no sessions", and a file that's missing,
61
+ * mid-write, or shaped wrong is skipped — session files churn constantly and
62
+ * none of that is an error worth surfacing.
63
+ */
64
+ export function readActiveSessions(props: Props): Map<string, SessionInfo> {
65
+ const staleMs = props.staleMs ?? DEFAULT_STALE_MS
66
+
67
+ const entries = props.fs.readdirSync(props.sessionsDir)
68
+ if (entries instanceof Error) return new Map()
69
+
70
+ const now = props.clock.millis()
71
+ const result = new Map<string, SessionInfo>()
72
+
73
+ for (const file of entries.filter((f) => f.endsWith(".json"))) {
74
+ const content = props.fs.readFileSync(join(props.sessionsDir, file))
75
+ if (content instanceof Error) continue
76
+
77
+ const json = safeJsonParse(content)
78
+ if (json instanceof Error) continue
79
+
80
+ const parsed = sessionFileSchema.safeParse(json)
81
+ if (!parsed.success) continue
82
+ const session = parsed.data
83
+
84
+ const alive = props.process.isAlive(session.pid)
85
+ if (!alive) {
86
+ const updatedAt = session.updatedAt ?? session.statusUpdatedAt ?? session.startedAt
87
+ if (updatedAt === undefined || now - updatedAt > staleMs) continue
88
+ }
89
+
90
+ const busy =
91
+ session.status !== undefined
92
+ ? session.status === "busy"
93
+ : isTranscriptActive({
94
+ fs: props.fs,
95
+ projectsDir: props.projectsDir,
96
+ cwd: session.cwd,
97
+ sessionId: session.sessionId,
98
+ now,
99
+ activeMs: props.transcriptActiveMs ?? DEFAULT_TRANSCRIPT_ACTIVE_MS,
100
+ })
101
+
102
+ result.set(session.sessionId, {
103
+ running: alive && busy,
104
+ name: session.name,
105
+ cwd: session.cwd,
106
+ })
107
+ }
108
+
109
+ return result
110
+ }
111
+
112
+ // Claude Code stores each transcript at
113
+ // `<projectsDir>/<cwd with "/" and "." flattened to "-">/<sessionId>.jsonl`.
114
+ function isTranscriptActive(props: {
115
+ fs: MinionFileSystem
116
+ projectsDir: string
117
+ cwd: string | undefined
118
+ sessionId: string
119
+ now: number
120
+ activeMs: number
121
+ }): boolean {
122
+ if (props.cwd === undefined) return false
123
+ const projectDir = props.cwd.replace(/[/\\.:]/g, "-")
124
+ const stat = props.fs.statSync(join(props.projectsDir, projectDir, `${props.sessionId}.jsonl`))
125
+ if (stat instanceof Error) return false
126
+ return props.now - stat.mtimeMs <= props.activeMs
127
+ }
@@ -0,0 +1,67 @@
1
+ import { MinionProcessRunner, type RunOptions } from "@lib/engine/process/process-runner"
2
+
3
+ export type MemoryProcessCall =
4
+ | { kind: "runInherit"; command: string[]; options: RunOptions }
5
+ | { kind: "spawnDetached"; command: string[]; options: RunOptions }
6
+ | { kind: "kill"; pid: number; signal: string }
7
+
8
+ export type MemoryRunInheritHandler = (
9
+ command: string[],
10
+ ) => number | Error | Promise<number | Error>
11
+ export type MemorySpawnDetachedHandler = (command: string[]) => number | Error
12
+ export type MemoryIsAliveHandler = (pid: number) => boolean
13
+
14
+ /** Records every call and lets tests stub exit codes, spawned pids, and liveness. */
15
+ export class MemoryMinionProcessRunner extends MinionProcessRunner {
16
+ readonly calls: MemoryProcessCall[] = []
17
+ readonly killed: { pid: number; signal: string }[] = []
18
+
19
+ private nextPid = 1000
20
+ private runInheritHandler: MemoryRunInheritHandler = () => 0
21
+ private spawnDetachedHandler: MemorySpawnDetachedHandler = () => {
22
+ this.nextPid += 1
23
+ return this.nextPid
24
+ }
25
+ private isAliveHandler: MemoryIsAliveHandler = () => false
26
+
27
+ onRunInherit(handler: MemoryRunInheritHandler): this {
28
+ this.runInheritHandler = handler
29
+ return this
30
+ }
31
+
32
+ onSpawnDetached(handler: MemorySpawnDetachedHandler): this {
33
+ this.spawnDetachedHandler = handler
34
+ return this
35
+ }
36
+
37
+ onIsAlive(handler: MemoryIsAliveHandler): this {
38
+ this.isAliveHandler = handler
39
+ return this
40
+ }
41
+
42
+ /** Convenience for tests that only care that a set of pids is "alive". */
43
+ setAlivePids(pids: Iterable<number>): this {
44
+ const alive = new Set(pids)
45
+ this.isAliveHandler = (pid) => alive.has(pid)
46
+ return this
47
+ }
48
+
49
+ async runInherit(command: string[], options: RunOptions = {}): Promise<number | Error> {
50
+ this.calls.push({ kind: "runInherit", command, options })
51
+ return await this.runInheritHandler(command)
52
+ }
53
+
54
+ spawnDetached(command: string[], options: RunOptions = {}): number | Error {
55
+ this.calls.push({ kind: "spawnDetached", command, options })
56
+ return this.spawnDetachedHandler(command)
57
+ }
58
+
59
+ kill(pid: number, signal: string = "SIGTERM"): void {
60
+ this.calls.push({ kind: "kill", pid, signal })
61
+ this.killed.push({ pid, signal })
62
+ }
63
+
64
+ isAlive(pid: number): boolean {
65
+ return this.isAliveHandler(pid)
66
+ }
67
+ }
@@ -0,0 +1,51 @@
1
+ import { toError } from "@lib/engine/errors"
2
+ import { MinionProcessRunner, type RunOptions } from "@lib/engine/process/process-runner"
3
+
4
+ export class NodeMinionProcessRunner extends MinionProcessRunner {
5
+ async runInherit(command: string[], options: RunOptions = {}): Promise<number | Error> {
6
+ try {
7
+ const [cmd, ...args] = command
8
+ const proc = Bun.spawn([cmd ?? "", ...args], {
9
+ cwd: options.cwd,
10
+ stdout: "inherit",
11
+ stderr: "inherit",
12
+ })
13
+ return await proc.exited
14
+ } catch (thrown) {
15
+ return toError(thrown)
16
+ }
17
+ }
18
+
19
+ spawnDetached(command: string[], options: RunOptions = {}): number | Error {
20
+ try {
21
+ const [cmd, ...args] = command
22
+ const proc = Bun.spawn([cmd ?? "", ...args], {
23
+ cwd: options.cwd,
24
+ stdout: "ignore",
25
+ stderr: "ignore",
26
+ stdin: "ignore",
27
+ })
28
+ proc.unref()
29
+ return proc.pid
30
+ } catch (thrown) {
31
+ return toError(thrown)
32
+ }
33
+ }
34
+
35
+ kill(pid: number, signal: string = "SIGTERM"): void {
36
+ try {
37
+ process.kill(pid, signal)
38
+ } catch {
39
+ // already dead — nothing to do
40
+ }
41
+ }
42
+
43
+ isAlive(pid: number): boolean {
44
+ try {
45
+ process.kill(pid, 0)
46
+ return true
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,21 @@
1
+ export type RunOptions = {
2
+ cwd?: string
3
+ }
4
+
5
+ /**
6
+ * Process boundary covering foreground builds, detached background spawns,
7
+ * and liveness checks. Default is NodeMinionProcessRunner (Bun.spawn);
8
+ * MemoryMinionProcessRunner records calls and lets tests stub responses.
9
+ *
10
+ * Fallible operations return `T | Error` (e.g. the executable doesn't exist)
11
+ * instead of throwing — implementations must catch their runtime's exceptions
12
+ * at this boundary.
13
+ */
14
+ export abstract class MinionProcessRunner {
15
+ /** Run a command to completion, inheriting stdio. Resolves to its exit code. */
16
+ abstract runInherit(command: string[], options?: RunOptions): Promise<number | Error>
17
+ /** Spawn a detached background process (keeps running after the parent exits). Returns its pid. */
18
+ abstract spawnDetached(command: string[], options?: RunOptions): number | Error
19
+ abstract kill(pid: number, signal?: string): void
20
+ abstract isAlive(pid: number): boolean
21
+ }
@@ -0,0 +1,22 @@
1
+ import { MinionRandomSource } from "@lib/engine/random/random-source"
2
+
3
+ type Props = {
4
+ /** Values replayed in order, then repeated from the start once exhausted. Defaults to always `0`. */
5
+ values?: number[]
6
+ }
7
+
8
+ export class MemoryMinionRandomSource extends MinionRandomSource {
9
+ private readonly values: number[]
10
+ private index = 0
11
+
12
+ constructor(props: Props = {}) {
13
+ super()
14
+ this.values = props.values && props.values.length > 0 ? props.values : [0]
15
+ }
16
+
17
+ next(): number {
18
+ const value = this.values[this.index % this.values.length] ?? 0
19
+ this.index += 1
20
+ return value
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ import { MinionRandomSource } from "@lib/engine/random/random-source"
2
+
3
+ export class NodeMinionRandomSource extends MinionRandomSource {
4
+ next(): number {
5
+ return Math.random()
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Randomness boundary used by the pet behavior picker. Default NodeMinionRandomSource
3
+ * wraps `Math.random()`; MemoryMinionRandomSource replays a fixed sequence of values
4
+ * for deterministic action-selection tests.
5
+ */
6
+ export abstract class MinionRandomSource {
7
+ /** A pseudo-random number in [0, 1), same contract as `Math.random()`. */
8
+ abstract next(): number
9
+ }
@@ -0,0 +1,31 @@
1
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
2
+
3
+ /** Parses a "YYYY-MM-DD" string as a UTC-midnight instant. */
4
+ function parseIsoDate(dateIso: string): number {
5
+ const [y, m, d] = dateIso.split("-").map(Number)
6
+ return Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1)
7
+ }
8
+
9
+ /** The `days` calendar dates ending on (and including) `todayIso`, in "YYYY-MM-DD" form, most-recent first. */
10
+ export function recentDates(todayIso: string, days: number): string[] {
11
+ const base = parseIsoDate(todayIso)
12
+ const dates: string[] = []
13
+ for (let i = 0; i < days; i++) {
14
+ dates.push(new Date(base - i * MS_PER_DAY).toISOString().slice(0, 10))
15
+ }
16
+ return dates
17
+ }
18
+
19
+ /** Sums a date-keyed map over the `days` calendar dates ending on `todayIso` (a rolling window, e.g. "this week" = 7). */
20
+ export function sumRecentDays(
21
+ byDate: Record<string, number>,
22
+ todayIso: string,
23
+ days: number,
24
+ ): number {
25
+ return recentDates(todayIso, days).reduce((sum, date) => sum + (byDate[date] ?? 0), 0)
26
+ }
27
+
28
+ /** Whether `nextIso` is exactly the calendar day after `previousIso`. */
29
+ export function isNextDay(previousIso: string, nextIso: string): boolean {
30
+ return parseIsoDate(nextIso) - parseIsoDate(previousIso) === MS_PER_DAY
31
+ }