@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.
- package/README.md +79 -33
- package/cli/src/app.ts +2 -0
- package/cli/src/factory.ts +8 -1
- package/cli/src/index.ts +15 -2
- package/cli/src/lib/body-validator.ts +13 -0
- package/cli/src/lib/format.ts +36 -0
- package/cli/src/router.ts +2 -1
- package/cli/src/routes/config/get/[key]/route.ts +7 -7
- package/cli/src/routes/config/list/route.ts +4 -5
- package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
- package/cli/src/routes/dev/route.ts +12 -7
- package/cli/src/routes/dex/route.ts +66 -0
- package/cli/src/routes/kill/route.ts +7 -5
- package/cli/src/routes/not-found.ts +1 -1
- package/cli/src/routes/reboot/route.ts +12 -7
- package/cli/src/routes/start/route.ts +7 -6
- package/cli/src/routes/status/route.ts +5 -5
- package/lib/engine/app/app-paths.ts +46 -0
- package/lib/engine/app/app-runner.ts +235 -0
- package/lib/engine/app/source-hash.ts +34 -0
- package/lib/engine/collection/achievements.ts +143 -0
- package/lib/engine/collection/collection-store.ts +43 -0
- package/lib/engine/collection/collection-tracker.ts +97 -0
- package/lib/engine/collection/moon.ts +26 -0
- package/lib/engine/collection/species.ts +275 -0
- package/lib/engine/config/config-store.ts +36 -0
- package/lib/engine/errors.ts +20 -0
- package/lib/engine/fs/file-system.ts +31 -0
- package/lib/engine/fs/json-file-store.ts +57 -0
- package/lib/engine/fs/memory-file-system.ts +106 -0
- package/lib/engine/fs/node-file-system.ts +87 -0
- package/lib/engine/gateway/gateway-daemon.ts +14 -0
- package/lib/engine/gateway/gateway-routes.ts +17 -0
- package/lib/engine/gateway/gateway-server.ts +126 -0
- package/lib/engine/gateway/pet-behavior.ts +164 -0
- package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
- package/lib/engine/gateway/sessions.ts +127 -0
- package/lib/engine/process/memory-process-runner.ts +67 -0
- package/lib/engine/process/node-process-runner.ts +51 -0
- package/lib/engine/process/process-runner.ts +21 -0
- package/lib/engine/random/memory-random-source.ts +22 -0
- package/lib/engine/random/node-random-source.ts +7 -0
- package/lib/engine/random/random-source.ts +9 -0
- package/lib/engine/stats/date-window.ts +31 -0
- package/lib/engine/stats/session-stats-tracker.ts +125 -0
- package/lib/engine/stats/stats-collector.ts +100 -0
- package/lib/engine/stats/stats-snapshot.ts +31 -0
- package/lib/engine/stats/token-usage-tracker.ts +170 -0
- package/lib/engine/time/clock.ts +11 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/index.ts +65 -0
- package/lib/minion.ts +194 -0
- package/package.json +9 -1
- package/swift/Sources/open-minion/open_minion.swift +5 -1
- package/tsconfig.json +3 -2
- package/cli/src/gateway.ts +0 -204
- 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,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
|
+
}
|