@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
package/lib/minion.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import type { MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
5
|
+
import { MemoryMinionFileSystem } from "@lib/engine/fs/memory-file-system"
|
|
6
|
+
import { NodeMinionFileSystem } from "@lib/engine/fs/node-file-system"
|
|
7
|
+
import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
|
|
8
|
+
import { MemoryMinionProcessRunner } from "@lib/engine/process/memory-process-runner"
|
|
9
|
+
import { NodeMinionProcessRunner } from "@lib/engine/process/node-process-runner"
|
|
10
|
+
import type { MinionClock } from "@lib/engine/time/clock"
|
|
11
|
+
import { MemoryMinionClock } from "@lib/engine/time/memory-clock"
|
|
12
|
+
import { NodeMinionClock } from "@lib/engine/time/node-clock"
|
|
13
|
+
import type { MinionRandomSource } from "@lib/engine/random/random-source"
|
|
14
|
+
import { MemoryMinionRandomSource } from "@lib/engine/random/memory-random-source"
|
|
15
|
+
import { NodeMinionRandomSource } from "@lib/engine/random/node-random-source"
|
|
16
|
+
import { type MinionPaths, resolveMinionPaths } from "@lib/engine/app/app-paths"
|
|
17
|
+
import { type MinionAppEvent, MinionAppRunner } from "@lib/engine/app/app-runner"
|
|
18
|
+
import { MinionConfigStore } from "@lib/engine/config/config-store"
|
|
19
|
+
import type { Achievement } from "@lib/engine/collection/achievements"
|
|
20
|
+
import { MinionCollectionStore } from "@lib/engine/collection/collection-store"
|
|
21
|
+
import { MinionCollectionTracker } from "@lib/engine/collection/collection-tracker"
|
|
22
|
+
import type { MinionSpecies } from "@lib/engine/collection/species"
|
|
23
|
+
import { MinionGatewayServer } from "@lib/engine/gateway/gateway-server"
|
|
24
|
+
import { resolveGatewayDaemonScript } from "@lib/engine/gateway/resolve-daemon-script"
|
|
25
|
+
import { SessionStatsTracker } from "@lib/engine/stats/session-stats-tracker"
|
|
26
|
+
import { TokenUsageTracker } from "@lib/engine/stats/token-usage-tracker"
|
|
27
|
+
import { MinionStatsCollector } from "@lib/engine/stats/stats-collector"
|
|
28
|
+
|
|
29
|
+
const SANDBOX_PACKAGE_ROOT = "/sandbox/pkg"
|
|
30
|
+
const SANDBOX_DATA_DIR = "/sandbox/.minion"
|
|
31
|
+
const SANDBOX_SESSIONS_DIR = "/sandbox/.claude/sessions"
|
|
32
|
+
const SANDBOX_PROJECTS_DIR = "/sandbox/.claude/projects"
|
|
33
|
+
|
|
34
|
+
export type MinionOptions = {
|
|
35
|
+
/** Filesystem boundary. Replace with MemoryMinionFileSystem to sandbox all disk I/O. */
|
|
36
|
+
fs?: MinionFileSystem
|
|
37
|
+
/** Process runner used to build/spawn the Swift app and the gateway daemon. */
|
|
38
|
+
process?: MinionProcessRunner
|
|
39
|
+
/** Clock used for session-staleness checks and pet-behavior timing. */
|
|
40
|
+
clock?: MinionClock
|
|
41
|
+
/** Randomness used by the pet behavior picker. */
|
|
42
|
+
random?: MinionRandomSource
|
|
43
|
+
/** Directory containing `swift/` (the package root). Defaults to the installed package root. */
|
|
44
|
+
packageRoot?: string
|
|
45
|
+
/** State directory (pid files, config, build output). Defaults to `~/.minion`. */
|
|
46
|
+
dataDir?: string
|
|
47
|
+
/** Directory of Claude Code session files the gateway watches. Defaults to `~/.claude/sessions`. */
|
|
48
|
+
sessionsDir?: string
|
|
49
|
+
/** Claude Code's transcript root, scanned for token usage. Defaults to `~/.claude/projects`. */
|
|
50
|
+
projectsDir?: string
|
|
51
|
+
/** Minion species catalog for `minion.collection`. Defaults to `DEFAULT_MINION_SPECIES` — pass your own to add species, retheme the built-ins, or attach real image `asset` references. */
|
|
52
|
+
species?: MinionSpecies[]
|
|
53
|
+
/** Achievement catalog for `minion.collection`. Defaults to `DEFAULT_ACHIEVEMENTS` — pass your own to replace it entirely. */
|
|
54
|
+
achievements?: Achievement[]
|
|
55
|
+
/** Progress events from `minion.app` (build started, ...). Defaults to a no-op — the library never writes to stdout itself. */
|
|
56
|
+
onEvent?: (event: MinionAppEvent) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MinionGatewayServerOptions = {
|
|
60
|
+
port?: number
|
|
61
|
+
tickMs?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Facade that wires every minion facet together and exposes the public surface.
|
|
66
|
+
*
|
|
67
|
+
* All side-effecting boundaries (filesystem, process, clock, random) are
|
|
68
|
+
* injected via Props — passing memory implementations gives a fully
|
|
69
|
+
* sandboxed Minion that touches no real disk, processes, or wall-clock time.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { Minion } from "@shigureni/minion"
|
|
74
|
+
*
|
|
75
|
+
* const minion = new Minion()
|
|
76
|
+
* const result = await minion.app.start() // { kind: "started", pid: 123 } | ... | Error
|
|
77
|
+
* if (result instanceof Error) console.error(result.message)
|
|
78
|
+
* console.log(minion.app.status().app.running)
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export class Minion {
|
|
82
|
+
readonly paths: MinionPaths
|
|
83
|
+
readonly config: MinionConfigStore
|
|
84
|
+
readonly app: MinionAppRunner
|
|
85
|
+
readonly stats: MinionStatsCollector
|
|
86
|
+
readonly collection: MinionCollectionTracker
|
|
87
|
+
|
|
88
|
+
private readonly fs: MinionFileSystem
|
|
89
|
+
private readonly process: MinionProcessRunner
|
|
90
|
+
private readonly clock: MinionClock
|
|
91
|
+
private readonly random: MinionRandomSource
|
|
92
|
+
private readonly sessionsDir: string
|
|
93
|
+
private readonly projectsDir: string
|
|
94
|
+
|
|
95
|
+
constructor(props: MinionOptions = {}) {
|
|
96
|
+
const packageRoot = props.packageRoot ?? defaultPackageRoot()
|
|
97
|
+
const fs = props.fs ?? new NodeMinionFileSystem()
|
|
98
|
+
const processRunner = props.process ?? new NodeMinionProcessRunner()
|
|
99
|
+
const clock = props.clock ?? new NodeMinionClock()
|
|
100
|
+
const random = props.random ?? new NodeMinionRandomSource()
|
|
101
|
+
const projectsDir = props.projectsDir ?? join(homedir(), ".claude", "projects")
|
|
102
|
+
|
|
103
|
+
this.fs = fs
|
|
104
|
+
this.process = processRunner
|
|
105
|
+
this.clock = clock
|
|
106
|
+
this.random = random
|
|
107
|
+
this.sessionsDir = props.sessionsDir ?? join(homedir(), ".claude", "sessions")
|
|
108
|
+
this.projectsDir = projectsDir
|
|
109
|
+
|
|
110
|
+
this.paths = resolveMinionPaths({ packageRoot, dataDir: props.dataDir })
|
|
111
|
+
|
|
112
|
+
this.config = new MinionConfigStore({ fs, path: this.paths.configFile })
|
|
113
|
+
|
|
114
|
+
this.app = new MinionAppRunner({
|
|
115
|
+
fs,
|
|
116
|
+
process: processRunner,
|
|
117
|
+
paths: this.paths,
|
|
118
|
+
gatewayCommand: ["bun", resolveGatewayDaemonScript()],
|
|
119
|
+
onEvent: props.onEvent,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
this.stats = new MinionStatsCollector({
|
|
123
|
+
fs,
|
|
124
|
+
process: processRunner,
|
|
125
|
+
clock,
|
|
126
|
+
sessionsDir: this.sessionsDir,
|
|
127
|
+
projectsDir,
|
|
128
|
+
sessionStats: new SessionStatsTracker({ fs, path: this.paths.sessionStatsFile }),
|
|
129
|
+
tokenUsage: new TokenUsageTracker({
|
|
130
|
+
fs,
|
|
131
|
+
clock,
|
|
132
|
+
path: this.paths.usageScanFile,
|
|
133
|
+
projectsDir,
|
|
134
|
+
}),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
this.collection = new MinionCollectionTracker({
|
|
138
|
+
store: new MinionCollectionStore({ fs, path: this.paths.collectionFile }),
|
|
139
|
+
species: props.species,
|
|
140
|
+
achievements: props.achievements,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
Object.freeze(this)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sandboxed Minion wired with in-memory implementations for every IO
|
|
148
|
+
* boundary. Touches no real disk, processes, or wall-clock time — safe
|
|
149
|
+
* for tests and ad-hoc experiments. Override individual fields via `props`.
|
|
150
|
+
*
|
|
151
|
+
* NOT covered by `inMemory()`: `gatewayServer().start()` still calls
|
|
152
|
+
* `Bun.serve` and binds a real port; pass `port: 0` to let the OS pick one.
|
|
153
|
+
*/
|
|
154
|
+
static inMemory(props: MinionOptions = {}): Minion {
|
|
155
|
+
// The default sandbox fs stamps mtimes off the sandbox clock, so
|
|
156
|
+
// recency-based logic (e.g. the token-scan backfill window) sees one
|
|
157
|
+
// consistent timeline instead of mixing wall-clock mtimes with a frozen clock.
|
|
158
|
+
const clock = props.clock ?? new MemoryMinionClock()
|
|
159
|
+
return new Minion({
|
|
160
|
+
...props,
|
|
161
|
+
fs: props.fs ?? new MemoryMinionFileSystem({ now: () => clock.millis() }),
|
|
162
|
+
process: props.process ?? new MemoryMinionProcessRunner(),
|
|
163
|
+
clock,
|
|
164
|
+
random: props.random ?? new MemoryMinionRandomSource(),
|
|
165
|
+
packageRoot: props.packageRoot ?? SANDBOX_PACKAGE_ROOT,
|
|
166
|
+
dataDir: props.dataDir ?? SANDBOX_DATA_DIR,
|
|
167
|
+
sessionsDir: props.sessionsDir ?? SANDBOX_SESSIONS_DIR,
|
|
168
|
+
projectsDir: props.projectsDir ?? SANDBOX_PROJECTS_DIR,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* In-process gateway server (HTTP + WebSocket) that watches the sessions
|
|
174
|
+
* directory and drives the pet's animation state. The daemon spawned by
|
|
175
|
+
* `app.start()` runs this same class out-of-process via `gateway-daemon.ts`.
|
|
176
|
+
*/
|
|
177
|
+
gatewayServer(options: MinionGatewayServerOptions = {}): MinionGatewayServer {
|
|
178
|
+
return new MinionGatewayServer({
|
|
179
|
+
fs: this.fs,
|
|
180
|
+
process: this.process,
|
|
181
|
+
clock: this.clock,
|
|
182
|
+
random: this.random,
|
|
183
|
+
sessionsDir: this.sessionsDir,
|
|
184
|
+
projectsDir: this.projectsDir,
|
|
185
|
+
port: options.port,
|
|
186
|
+
tickMs: options.tickMs,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultPackageRoot(): string {
|
|
192
|
+
// URL.pathname はパスに空白や非ASCII文字があるとパーセントエンコードされたまま壊れる
|
|
193
|
+
return fileURLToPath(new URL("..", import.meta.url))
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shigureni/minion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Desktop pet that reacts to your Claude Code session activity",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -15,12 +15,20 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"cli/src",
|
|
18
|
+
"lib",
|
|
18
19
|
"swift",
|
|
19
20
|
"tsconfig.json",
|
|
20
21
|
"!cli/src/**/*.test.ts",
|
|
22
|
+
"!lib/**/*.test.ts",
|
|
21
23
|
"!swift/.build"
|
|
22
24
|
],
|
|
23
25
|
"type": "module",
|
|
26
|
+
"main": "./lib/index.ts",
|
|
27
|
+
"types": "./lib/index.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./lib/index.ts",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
24
32
|
"publishConfig": {
|
|
25
33
|
"access": "public"
|
|
26
34
|
},
|
|
@@ -280,6 +280,9 @@ final class Pet {
|
|
|
280
280
|
window.setFrameOrigin(NSPoint(x: x, y: y))
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// スポーン時の向きはランダム。移動する行動を取るまでこの向きを保つ。
|
|
284
|
+
facingLeft = Bool.random()
|
|
285
|
+
|
|
283
286
|
window.orderFrontRegardless()
|
|
284
287
|
}
|
|
285
288
|
|
|
@@ -359,6 +362,7 @@ final class Pet {
|
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
// セッションが idle の間は、その場でうずくまって寝る(掴む/なでるはできる)。
|
|
365
|
+
// ただし待機クリップ自体のコマ送りは止めない。止めると単なる1枚絵で固まって見える。
|
|
362
366
|
let sleeping = !sessionRunning
|
|
363
367
|
if !sleeping {
|
|
364
368
|
if feedTicksRemaining > 0 {
|
|
@@ -368,8 +372,8 @@ final class Pet {
|
|
|
368
372
|
}
|
|
369
373
|
}
|
|
370
374
|
stepBehavior()
|
|
371
|
-
advanceAnimationFrame()
|
|
372
375
|
}
|
|
376
|
+
advanceAnimationFrame()
|
|
373
377
|
|
|
374
378
|
petView.isAsleep = sleeping
|
|
375
379
|
petView.facingLeft = facingLeft
|
package/tsconfig.json
CHANGED
package/cli/src/gateway.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
import { readdirSync, readFileSync } from "node:fs"
|
|
4
|
-
import { homedir } from "node:os"
|
|
5
|
-
import { join } from "node:path"
|
|
6
|
-
|
|
7
|
-
const SESSIONS_DIR = join(homedir(), ".claude", "sessions")
|
|
8
|
-
const STALE_MS = 8 * 60 * 1000
|
|
9
|
-
const TICK_MS = 250
|
|
10
|
-
const PORT = Number(process.env.MINION_GATEWAY_PORT) || 4756
|
|
11
|
-
|
|
12
|
-
// Swift側 PetView.clips と同じ並び順・重み(合計100)。座標や速度はSwiftが持つ
|
|
13
|
-
// clip定義とスクリーン形状に依存するため、ここでは「どの行動を・どれだけ続けるか」だけを決める。
|
|
14
|
-
const ACTIONS: { durationMs: [number, number]; weight: number }[] = [
|
|
15
|
-
{ weight: 14, durationMs: [3000, 6000] }, // 0 待機
|
|
16
|
-
{ weight: 10, durationMs: [3000, 6000] }, // 1 歩く
|
|
17
|
-
{ weight: 28, durationMs: [4000, 7000] }, // 2 走る
|
|
18
|
-
{ weight: 3, durationMs: [1000, 2000] }, // 3 ジャンプ1
|
|
19
|
-
{ weight: 3, durationMs: [1000, 2000] }, // 4 ジャンプ2
|
|
20
|
-
{ weight: 3, durationMs: [1000, 2000] }, // 5 ジャンプ3
|
|
21
|
-
{ weight: 3, durationMs: [1000, 2000] }, // 6 ジャンプ4
|
|
22
|
-
{ weight: 8, durationMs: [4000, 7000] }, // 7 座る1
|
|
23
|
-
{ weight: 8, durationMs: [4000, 7000] }, // 8 座る2
|
|
24
|
-
{ weight: 8, durationMs: [4000, 7000] }, // 9 座る3
|
|
25
|
-
{ weight: 8, durationMs: [4000, 7000] }, // 10 座る4
|
|
26
|
-
{ weight: 2, durationMs: [2000, 3000] }, // 11 食べる1
|
|
27
|
-
{ weight: 2, durationMs: [2000, 3000] }, // 12 食べる2
|
|
28
|
-
]
|
|
29
|
-
const IDLE_CLIP = 0
|
|
30
|
-
|
|
31
|
-
function pickAction(): { clipIndex: number; durationMs: number } {
|
|
32
|
-
const roll = Math.random() * 100
|
|
33
|
-
let cumulative = 0
|
|
34
|
-
let chosen = IDLE_CLIP
|
|
35
|
-
for (let i = 0; i < ACTIONS.length; i++) {
|
|
36
|
-
cumulative += ACTIONS[i].weight
|
|
37
|
-
if (roll < cumulative) {
|
|
38
|
-
chosen = i
|
|
39
|
-
break
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const [min, max] = ACTIONS[chosen].durationMs
|
|
43
|
-
return { clipIndex: chosen, durationMs: min + Math.random() * (max - min) }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isPidAlive(pid: number): boolean {
|
|
47
|
-
try {
|
|
48
|
-
process.kill(pid, 0)
|
|
49
|
-
return true
|
|
50
|
-
} catch {
|
|
51
|
-
return false
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// updatedAt はステータスが切り替わった時にしか更新されないため、
|
|
56
|
-
// ずっと busy のまま長時間動いているセッションは経過時間だけでは判定できない。
|
|
57
|
-
// プロセスが生きている間は経過時間を無視し、死んでから8分の猶予で消す。
|
|
58
|
-
interface SessionInfo {
|
|
59
|
-
running: boolean
|
|
60
|
-
name: string
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function readActiveSessions(): Map<string, SessionInfo> {
|
|
64
|
-
let files: string[]
|
|
65
|
-
try {
|
|
66
|
-
files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"))
|
|
67
|
-
} catch {
|
|
68
|
-
return new Map()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const now = Date.now()
|
|
72
|
-
const result = new Map<string, SessionInfo>()
|
|
73
|
-
|
|
74
|
-
for (const file of files) {
|
|
75
|
-
try {
|
|
76
|
-
const raw = JSON.parse(readFileSync(join(SESSIONS_DIR, file), "utf8"))
|
|
77
|
-
if (typeof raw.sessionId !== "string" || typeof raw.pid !== "number") continue
|
|
78
|
-
|
|
79
|
-
const alive = isPidAlive(raw.pid)
|
|
80
|
-
if (!alive) {
|
|
81
|
-
const updatedAt = raw.updatedAt ?? raw.statusUpdatedAt ?? raw.startedAt
|
|
82
|
-
if (typeof updatedAt !== "number" || now - updatedAt > STALE_MS) continue
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
result.set(raw.sessionId, {
|
|
86
|
-
running: alive && raw.status === "busy",
|
|
87
|
-
name: typeof raw.name === "string" ? raw.name : "",
|
|
88
|
-
})
|
|
89
|
-
} catch {
|
|
90
|
-
// 書き込み途中などで壊れているファイルは無視
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return result
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface PetBehavior {
|
|
98
|
-
running: boolean
|
|
99
|
-
name: string
|
|
100
|
-
clipIndex: number
|
|
101
|
-
actionEndsAt: number
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const behaviors = new Map<string, PetBehavior>()
|
|
105
|
-
const clients = new Set()
|
|
106
|
-
|
|
107
|
-
function snapshot() {
|
|
108
|
-
return Array.from(behaviors.entries()).map(([id, b]) => ({
|
|
109
|
-
id,
|
|
110
|
-
state: b.running ? "running" : "sleeping",
|
|
111
|
-
clipIndex: b.clipIndex,
|
|
112
|
-
name: b.name,
|
|
113
|
-
}))
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function broadcast(): void {
|
|
117
|
-
const payload = JSON.stringify({ sessions: snapshot() })
|
|
118
|
-
for (const ws of clients) {
|
|
119
|
-
;(ws as { send: (data: string) => void }).send(payload)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function tick(): void {
|
|
124
|
-
const now = Date.now()
|
|
125
|
-
const activeSessions = readActiveSessions()
|
|
126
|
-
let dirty = false
|
|
127
|
-
|
|
128
|
-
for (const id of behaviors.keys()) {
|
|
129
|
-
if (!activeSessions.has(id)) {
|
|
130
|
-
behaviors.delete(id)
|
|
131
|
-
dirty = true
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (const [id, info] of activeSessions) {
|
|
136
|
-
const existing = behaviors.get(id)
|
|
137
|
-
|
|
138
|
-
if (!existing) {
|
|
139
|
-
const action = info.running ? pickAction() : { clipIndex: IDLE_CLIP, durationMs: 0 }
|
|
140
|
-
behaviors.set(id, {
|
|
141
|
-
running: info.running,
|
|
142
|
-
name: info.name,
|
|
143
|
-
clipIndex: action.clipIndex,
|
|
144
|
-
actionEndsAt: now + action.durationMs,
|
|
145
|
-
})
|
|
146
|
-
dirty = true
|
|
147
|
-
continue
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (existing.name !== info.name) {
|
|
151
|
-
existing.name = info.name
|
|
152
|
-
dirty = true
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (existing.running !== info.running) {
|
|
156
|
-
existing.running = info.running
|
|
157
|
-
if (info.running) {
|
|
158
|
-
const action = pickAction()
|
|
159
|
-
existing.clipIndex = action.clipIndex
|
|
160
|
-
existing.actionEndsAt = now + action.durationMs
|
|
161
|
-
}
|
|
162
|
-
dirty = true
|
|
163
|
-
} else if (info.running && now >= existing.actionEndsAt) {
|
|
164
|
-
const action = pickAction()
|
|
165
|
-
existing.clipIndex = action.clipIndex
|
|
166
|
-
existing.actionEndsAt = now + action.durationMs
|
|
167
|
-
dirty = true
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (dirty) broadcast()
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
setInterval(tick, TICK_MS)
|
|
175
|
-
tick()
|
|
176
|
-
|
|
177
|
-
Bun.serve({
|
|
178
|
-
port: PORT,
|
|
179
|
-
fetch(req, server) {
|
|
180
|
-
const url = new URL(req.url)
|
|
181
|
-
if (url.pathname === "/sessions") {
|
|
182
|
-
return Response.json({ sessions: snapshot() })
|
|
183
|
-
}
|
|
184
|
-
if (url.pathname === "/ws") {
|
|
185
|
-
if (server.upgrade(req)) return undefined
|
|
186
|
-
return new Response("upgrade failed", { status: 400 })
|
|
187
|
-
}
|
|
188
|
-
return new Response("not found", { status: 404 })
|
|
189
|
-
},
|
|
190
|
-
websocket: {
|
|
191
|
-
open(ws) {
|
|
192
|
-
clients.add(ws)
|
|
193
|
-
ws.send(JSON.stringify({ sessions: snapshot() }))
|
|
194
|
-
},
|
|
195
|
-
close(ws) {
|
|
196
|
-
clients.delete(ws)
|
|
197
|
-
},
|
|
198
|
-
message() {
|
|
199
|
-
// クライアントからのメッセージは使わない
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
console.log(`gateway listening on :${PORT}`)
|
package/cli/src/lib/process.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
3
|
-
closeSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
openSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "node:fs"
|
|
11
|
-
import { homedir } from "node:os"
|
|
12
|
-
import { join } from "node:path"
|
|
13
|
-
|
|
14
|
-
const PACKAGE_ROOT = join(import.meta.dir, "..", "..", "..")
|
|
15
|
-
const APP_ROOT = join(PACKAGE_ROOT, "swift")
|
|
16
|
-
// bunx はパッケージ本体をキャッシュディレクトリに展開するため、状態とビルド
|
|
17
|
-
// 成果物はパッケージの場所に依存しないよう ~/.minion に置く。
|
|
18
|
-
const DATA_DIR = join(homedir(), ".minion")
|
|
19
|
-
const BUILD_PATH = join(DATA_DIR, "build")
|
|
20
|
-
const BIN_PATH = join(BUILD_PATH, "release", "open-minion")
|
|
21
|
-
const DEBUG_BIN_PATH = join(BUILD_PATH, "debug", "open-minion")
|
|
22
|
-
const DATA_FILE = join(DATA_DIR, "data.json")
|
|
23
|
-
const PID_FILE = join(DATA_DIR, "pid")
|
|
24
|
-
const CONFIG_FILE = join(DATA_DIR, "config.json")
|
|
25
|
-
const GATEWAY_SCRIPT = join(import.meta.dir, "..", "gateway.ts")
|
|
26
|
-
const GATEWAY_PID_FILE = join(DATA_DIR, "gateway.pid")
|
|
27
|
-
|
|
28
|
-
interface BuildData {
|
|
29
|
-
sourceHash: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function computeSourceHash(): string {
|
|
33
|
-
const files = [
|
|
34
|
-
join(APP_ROOT, "Package.swift"),
|
|
35
|
-
...Array.from(new Bun.Glob("Sources/**/*.swift").scanSync({ cwd: APP_ROOT })).map((f) =>
|
|
36
|
-
join(APP_ROOT, f),
|
|
37
|
-
),
|
|
38
|
-
].sort()
|
|
39
|
-
const hash = createHash("sha256")
|
|
40
|
-
for (const file of files) {
|
|
41
|
-
hash.update(readFileSync(file))
|
|
42
|
-
}
|
|
43
|
-
return hash.digest("hex")
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function readBuildData(): BuildData | null {
|
|
47
|
-
if (!existsSync(DATA_FILE)) return null
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(readFileSync(DATA_FILE, "utf8"))
|
|
50
|
-
} catch {
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function writeBuildData(data: BuildData): void {
|
|
56
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
57
|
-
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readPid(pidFile: string = PID_FILE): number | null {
|
|
61
|
-
if (!existsSync(pidFile)) return null
|
|
62
|
-
const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10)
|
|
63
|
-
return Number.isNaN(pid) ? null : pid
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function isRunning(pid: number): boolean {
|
|
67
|
-
try {
|
|
68
|
-
process.kill(pid, 0)
|
|
69
|
-
return true
|
|
70
|
-
} catch {
|
|
71
|
-
return false
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function acquireStartLock(): boolean {
|
|
76
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
77
|
-
try {
|
|
78
|
-
closeSync(openSync(PID_FILE, "wx"))
|
|
79
|
-
return true
|
|
80
|
-
} catch {
|
|
81
|
-
return false
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function build(debug: boolean): Promise<boolean> {
|
|
86
|
-
console.log("ビルド中...")
|
|
87
|
-
const args = debug
|
|
88
|
-
? ["swift", "build", "--scratch-path", BUILD_PATH]
|
|
89
|
-
: ["swift", "build", "-c", "release", "--scratch-path", BUILD_PATH]
|
|
90
|
-
const proc = Bun.spawn(args, {
|
|
91
|
-
cwd: APP_ROOT,
|
|
92
|
-
stdout: "inherit",
|
|
93
|
-
stderr: "inherit",
|
|
94
|
-
})
|
|
95
|
-
const code = await proc.exited
|
|
96
|
-
if (code !== 0) {
|
|
97
|
-
console.error("ビルド失敗")
|
|
98
|
-
return false
|
|
99
|
-
}
|
|
100
|
-
return true
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function startGateway(): void {
|
|
104
|
-
const pid = readPid(GATEWAY_PID_FILE)
|
|
105
|
-
if (pid !== null && isRunning(pid)) return
|
|
106
|
-
|
|
107
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
108
|
-
const proc = Bun.spawn(["bun", GATEWAY_SCRIPT], {
|
|
109
|
-
stdout: "ignore",
|
|
110
|
-
stderr: "ignore",
|
|
111
|
-
stdin: "ignore",
|
|
112
|
-
})
|
|
113
|
-
proc.unref()
|
|
114
|
-
writeFileSync(GATEWAY_PID_FILE, String(proc.pid))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function killGateway(): void {
|
|
118
|
-
const pid = readPid(GATEWAY_PID_FILE)
|
|
119
|
-
if (pid !== null && isRunning(pid)) {
|
|
120
|
-
process.kill(pid, "SIGTERM")
|
|
121
|
-
}
|
|
122
|
-
rmSync(GATEWAY_PID_FILE, { force: true })
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function startApp(debug: boolean): Promise<string> {
|
|
126
|
-
const existingPid = readPid()
|
|
127
|
-
if (existingPid !== null) {
|
|
128
|
-
if (isRunning(existingPid)) {
|
|
129
|
-
return `すでに起動中 (pid ${existingPid})`
|
|
130
|
-
}
|
|
131
|
-
rmSync(PID_FILE, { force: true })
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (!acquireStartLock()) {
|
|
135
|
-
return "他の起動処理と競合したため中止"
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const binPath = debug ? DEBUG_BIN_PATH : BIN_PATH
|
|
139
|
-
|
|
140
|
-
if (debug) {
|
|
141
|
-
if (!(await build(true))) {
|
|
142
|
-
rmSync(PID_FILE, { force: true })
|
|
143
|
-
return "ビルド失敗"
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
const currentHash = computeSourceHash()
|
|
147
|
-
const buildData = readBuildData()
|
|
148
|
-
if (!existsSync(binPath) || buildData?.sourceHash !== currentHash) {
|
|
149
|
-
if (!(await build(false))) {
|
|
150
|
-
rmSync(PID_FILE, { force: true })
|
|
151
|
-
return "ビルド失敗"
|
|
152
|
-
}
|
|
153
|
-
writeBuildData({ sourceHash: currentHash })
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const proc = Bun.spawn([binPath], {
|
|
158
|
-
cwd: APP_ROOT,
|
|
159
|
-
stdout: "ignore",
|
|
160
|
-
stderr: "ignore",
|
|
161
|
-
stdin: "ignore",
|
|
162
|
-
})
|
|
163
|
-
proc.unref()
|
|
164
|
-
writeFileSync(PID_FILE, String(proc.pid))
|
|
165
|
-
startGateway()
|
|
166
|
-
return `起動した (pid ${proc.pid})`
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function killApp(): string {
|
|
170
|
-
killGateway()
|
|
171
|
-
const pid = readPid()
|
|
172
|
-
if (!pid || !isRunning(pid)) {
|
|
173
|
-
if (existsSync(PID_FILE)) rmSync(PID_FILE, { force: true })
|
|
174
|
-
return "起動していない"
|
|
175
|
-
}
|
|
176
|
-
process.kill(pid, "SIGTERM")
|
|
177
|
-
rmSync(PID_FILE, { force: true })
|
|
178
|
-
return `停止した (pid ${pid})`
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function statusApp(): string {
|
|
182
|
-
const pid = readPid()
|
|
183
|
-
const appLine = pid && isRunning(pid) ? `起動中 (pid ${pid})` : "停止中"
|
|
184
|
-
|
|
185
|
-
const gatewayPid = readPid(GATEWAY_PID_FILE)
|
|
186
|
-
const gatewayLine =
|
|
187
|
-
gatewayPid && isRunning(gatewayPid) ? `gateway起動中 (pid ${gatewayPid})` : "gateway停止中"
|
|
188
|
-
|
|
189
|
-
return [appLine, gatewayLine].join("\n")
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function readConfig(): Record<string, string> {
|
|
193
|
-
if (!existsSync(CONFIG_FILE)) return {}
|
|
194
|
-
try {
|
|
195
|
-
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
|
|
196
|
-
} catch {
|
|
197
|
-
return {}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function writeConfig(config: Record<string, string>): void {
|
|
202
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
203
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
204
|
-
}
|