@aaroncql/pim-agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,89 @@
1
+ import type { Api } from "grammy";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+
7
+ import { Fs } from "../shared/Fs";
8
+ import { type TelegramConfig } from "./Config";
9
+ import { SessionRegistry } from "./SessionRegistry";
10
+ import type { SessionSettings } from "./Session";
11
+ import { TaskScheduler } from "./TaskScheduler";
12
+
13
+ let tmp: string;
14
+ let config: TelegramConfig;
15
+ const stubApi = {} as Api;
16
+ const stubScheduler = new TaskScheduler({
17
+ configDir: "/tmp",
18
+ runTask: async () => {},
19
+ });
20
+
21
+ beforeEach(async () => {
22
+ tmp = await mkdtemp(join(tmpdir(), "pim-session-registry-test-"));
23
+ config = {
24
+ token: "token",
25
+ allow: [],
26
+ cwd: tmp,
27
+ configDir: tmp,
28
+ };
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ async function readState(): Promise<Record<string, SessionSettings>> {
36
+ return Fs.readJsonOrEmpty<Record<string, SessionSettings>>(
37
+ join(tmp, "state.json"),
38
+ {}
39
+ );
40
+ }
41
+
42
+ async function writeState(
43
+ state: Record<string, SessionSettings>
44
+ ): Promise<void> {
45
+ await Fs.writeAtomic(join(tmp, "state.json"), JSON.stringify(state, null, 2));
46
+ }
47
+
48
+ describe("SessionRegistry state", () => {
49
+ test("loads persisted state and preserves it when mutating another session", async () => {
50
+ await writeState({
51
+ "1-main": {
52
+ cwd: "/repo",
53
+ cumulativeCost: 12.5,
54
+ sessionPath: "/sessions/one.jsonl",
55
+ },
56
+ });
57
+
58
+ const registry = new SessionRegistry(config, stubApi, stubScheduler);
59
+ await registry.init();
60
+ const session = registry.get({ chatId: 2, threadId: undefined });
61
+ await session.setThinkingLevel("off");
62
+
63
+ const loaded = await readState();
64
+ expect(loaded["1-main"]).toEqual({
65
+ cwd: "/repo",
66
+ cumulativeCost: 12.5,
67
+ sessionPath: "/sessions/one.jsonl",
68
+ });
69
+ expect(loaded["2-main"]?.thinkingLevel).toBe("off");
70
+ });
71
+
72
+ test("does not flush state when disposed before init", async () => {
73
+ await writeState({
74
+ "1-main": {
75
+ cwd: "/repo",
76
+ cumulativeCost: 12.5,
77
+ },
78
+ });
79
+
80
+ const registry = new SessionRegistry(config, stubApi, stubScheduler);
81
+ await registry.disposeAll();
82
+
83
+ const loaded = await readState();
84
+ expect(loaded["1-main"]).toEqual({
85
+ cwd: "/repo",
86
+ cumulativeCost: 12.5,
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,170 @@
1
+ import {
2
+ AuthStorage,
3
+ getAgentDir,
4
+ ModelRegistry,
5
+ SettingsManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { Api } from "grammy";
8
+ import { mkdir } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+
11
+ import { Fs } from "../shared/Fs";
12
+ import { type TelegramConfig } from "./Config";
13
+ import { Session, type SessionId, type SessionSettings } from "./Session";
14
+ import type { TaskScheduler } from "./TaskScheduler";
15
+
16
+ const LRU_CAP = 16;
17
+
18
+ export class SessionRegistry {
19
+ private readonly config: TelegramConfig;
20
+ private readonly api: Api;
21
+ private readonly scheduler: TaskScheduler;
22
+ private readonly cache = new Map<string, Session>();
23
+ private readonly authStorage: AuthStorage;
24
+ private readonly modelRegistry: ModelRegistry;
25
+ private readonly settingsManagers = new Map<string, SettingsManager>();
26
+ private readonly agentDir: string;
27
+ private settings: Map<string, SessionSettings> = new Map();
28
+ private initialized = false;
29
+ private initPromise: Promise<void> | undefined;
30
+ private botUsername: string | undefined;
31
+
32
+ public constructor(
33
+ config: TelegramConfig,
34
+ api: Api,
35
+ scheduler: TaskScheduler
36
+ ) {
37
+ this.config = config;
38
+ this.api = api;
39
+ this.scheduler = scheduler;
40
+ this.agentDir = getAgentDir();
41
+ this.authStorage = AuthStorage.create(join(this.agentDir, "auth.json"));
42
+ this.modelRegistry = ModelRegistry.create(
43
+ this.authStorage,
44
+ join(this.agentDir, "models.json")
45
+ );
46
+ }
47
+
48
+ public setBotUsername(username: string): void {
49
+ this.botUsername = username;
50
+ }
51
+
52
+ public async init(): Promise<void> {
53
+ if (this.initialized) {
54
+ return;
55
+ }
56
+ this.initPromise ??= this.bootstrap().catch((err: unknown) => {
57
+ this.initPromise = undefined;
58
+ throw err;
59
+ });
60
+ await this.initPromise;
61
+ }
62
+
63
+ public get(sessionId: SessionId): Session {
64
+ this.requireInitialized();
65
+ const key = Session.encodeId(sessionId);
66
+ const cached = this.cache.get(key);
67
+ if (cached) {
68
+ cached.lastUsed = Date.now();
69
+ return cached;
70
+ }
71
+ this.evictIfNeeded();
72
+ const session = new Session({
73
+ id: sessionId,
74
+ settings: this.settings.get(key) ?? {},
75
+ config: this.config,
76
+ api: this.api,
77
+ agentDir: this.agentDir,
78
+ authStorage: this.authStorage,
79
+ modelRegistry: this.modelRegistry,
80
+ scheduler: this.scheduler,
81
+ settingsManagerFor: (cwd) => this.settingsManagerFor(cwd),
82
+ persistSettings: (patch) => this.persistSettings(key, patch),
83
+ getBotUsername: () => this.botUsername,
84
+ });
85
+ this.cache.set(key, session);
86
+ return session;
87
+ }
88
+
89
+ public async disposeAll(): Promise<void> {
90
+ for (const session of this.cache.values()) {
91
+ session.dispose();
92
+ }
93
+ this.cache.clear();
94
+ if (this.initialized) {
95
+ await this.flushSettings();
96
+ }
97
+ }
98
+
99
+ private async bootstrap(): Promise<void> {
100
+ const loaded = await Fs.readJsonOrEmpty<Record<string, SessionSettings>>(
101
+ join(this.config.configDir, "state.json"),
102
+ {}
103
+ );
104
+ this.settings = new Map(Object.entries(loaded));
105
+ await mkdir(join(this.config.configDir, "sessions"), { recursive: true });
106
+ await mkdir(join(this.config.configDir, "isolated-sessions"), {
107
+ recursive: true,
108
+ });
109
+ await mkdir(join(this.config.configDir, "instructions"), {
110
+ recursive: true,
111
+ });
112
+ this.initialized = true;
113
+ }
114
+
115
+ private requireInitialized(): void {
116
+ if (!this.initialized) {
117
+ throw new Error("SessionRegistry.init() must complete before use");
118
+ }
119
+ }
120
+
121
+ private async persistSettings(
122
+ key: string,
123
+ patch: Partial<SessionSettings>
124
+ ): Promise<void> {
125
+ const prev = this.settings.get(key) ?? {};
126
+ this.settings.set(key, { ...prev, ...patch });
127
+ await this.flushSettings();
128
+ }
129
+
130
+ private async flushSettings(): Promise<void> {
131
+ try {
132
+ await Fs.writeAtomic(
133
+ join(this.config.configDir, "state.json"),
134
+ JSON.stringify(Object.fromEntries(this.settings), null, 2)
135
+ );
136
+ } catch (err) {
137
+ console.warn(`[registry] state save failed:`, err);
138
+ }
139
+ }
140
+
141
+ private settingsManagerFor(cwd: string): SettingsManager {
142
+ const existing = this.settingsManagers.get(cwd);
143
+ if (existing) {
144
+ return existing;
145
+ }
146
+ const settingsManager = SettingsManager.create(cwd, this.agentDir);
147
+ this.settingsManagers.set(cwd, settingsManager);
148
+ return settingsManager;
149
+ }
150
+
151
+ private evictIfNeeded(): void {
152
+ if (this.cache.size < LRU_CAP) {
153
+ return;
154
+ }
155
+ let oldestKey: string | undefined;
156
+ let oldestTime = Infinity;
157
+ for (const [k, v] of this.cache) {
158
+ if (v.lastUsed < oldestTime) {
159
+ oldestTime = v.lastUsed;
160
+ oldestKey = k;
161
+ }
162
+ }
163
+ if (!oldestKey) {
164
+ return;
165
+ }
166
+ const entry = this.cache.get(oldestKey)!;
167
+ entry.dispose();
168
+ this.cache.delete(oldestKey);
169
+ }
170
+ }
@@ -0,0 +1,357 @@
1
+ import { mkdir, realpath, rm, stat, unlink } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { Fs } from "../shared/Fs";
6
+
7
+ const UNIT_NAME = "pim-telegram";
8
+ const LAUNCHD_LABEL = "com.aaroncql.pim-telegram";
9
+ const NPM_PACKAGE = "@aaroncql/pim-agent";
10
+ const CONFIRM_FILE = "update-confirm.json";
11
+
12
+ export type UpdateConfirmEntry = {
13
+ readonly chatId: number;
14
+ readonly threadId: number | undefined;
15
+ readonly messageId: number;
16
+ };
17
+
18
+ export type Mode = {
19
+ readonly kind: "dev" | "prod";
20
+ readonly packageRoot: string;
21
+ readonly pimEntry: string;
22
+ readonly bunPath: string;
23
+ };
24
+
25
+ export type UpdateResult =
26
+ | { readonly ok: true }
27
+ | { readonly ok: false; readonly error: string };
28
+
29
+ export class Supervisor {
30
+ public static async install(): Promise<void> {
31
+ const mode = await Supervisor.detectMode();
32
+ console.log(`[install] ${mode.kind} mode, root=${mode.packageRoot}`);
33
+ if (process.platform === "linux") {
34
+ const path = Supervisor.systemdUnitPath();
35
+ await Fs.writeAtomic(path, Supervisor.systemdUnit(mode));
36
+ console.log(`[install] wrote ${path}`);
37
+ await Supervisor.runOrThrow(["systemctl", "--user", "daemon-reload"]);
38
+ await Supervisor.runOrThrow([
39
+ "systemctl",
40
+ "--user",
41
+ "enable",
42
+ "--now",
43
+ UNIT_NAME,
44
+ ]);
45
+ console.log(`[install] enabled and started ${UNIT_NAME}.service`);
46
+ if (!(await Supervisor.lingerEnabled())) {
47
+ console.log(
48
+ `[install] hint: run 'loginctl enable-linger' so the service starts at boot without an active login`
49
+ );
50
+ }
51
+ return;
52
+ }
53
+ if (process.platform === "darwin") {
54
+ await mkdir(join(homedir(), "Library", "Logs"), { recursive: true });
55
+ const path = Supervisor.launchdPlistPath();
56
+ await Fs.writeAtomic(path, Supervisor.launchdPlist(mode));
57
+ console.log(`[install] wrote ${path}`);
58
+ const uid = process.getuid?.() ?? 0;
59
+ try {
60
+ await Supervisor.runOrThrow([
61
+ "launchctl",
62
+ "bootout",
63
+ `gui/${uid}/${LAUNCHD_LABEL}`,
64
+ ]);
65
+ } catch {
66
+ // bootout fails when the service isn't currently loaded; safe to ignore before bootstrap
67
+ }
68
+ await Supervisor.runOrThrow([
69
+ "launchctl",
70
+ "bootstrap",
71
+ `gui/${uid}`,
72
+ path,
73
+ ]);
74
+ console.log(`[install] bootstrapped ${LAUNCHD_LABEL}`);
75
+ return;
76
+ }
77
+ throw new Error(`Unsupported platform: ${process.platform}`);
78
+ }
79
+
80
+ public static async uninstall(): Promise<void> {
81
+ if (process.platform === "linux") {
82
+ const path = Supervisor.systemdUnitPath();
83
+ try {
84
+ await Supervisor.runOrThrow([
85
+ "systemctl",
86
+ "--user",
87
+ "disable",
88
+ "--now",
89
+ UNIT_NAME,
90
+ ]);
91
+ } catch (err) {
92
+ console.warn(`[uninstall] disable failed:`, (err as Error).message);
93
+ }
94
+ try {
95
+ await rm(path);
96
+ console.log(`[uninstall] removed ${path}`);
97
+ } catch (err) {
98
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
99
+ throw err;
100
+ }
101
+ }
102
+ try {
103
+ await Supervisor.runOrThrow(["systemctl", "--user", "daemon-reload"]);
104
+ } catch (err) {
105
+ console.warn(
106
+ `[uninstall] daemon-reload failed:`,
107
+ (err as Error).message
108
+ );
109
+ }
110
+ return;
111
+ }
112
+ if (process.platform === "darwin") {
113
+ const path = Supervisor.launchdPlistPath();
114
+ const uid = process.getuid?.() ?? 0;
115
+ try {
116
+ await Supervisor.runOrThrow([
117
+ "launchctl",
118
+ "bootout",
119
+ `gui/${uid}/${LAUNCHD_LABEL}`,
120
+ ]);
121
+ } catch (err) {
122
+ console.warn(`[uninstall] bootout failed:`, (err as Error).message);
123
+ }
124
+ try {
125
+ await rm(path);
126
+ console.log(`[uninstall] removed ${path}`);
127
+ } catch (err) {
128
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
129
+ throw err;
130
+ }
131
+ }
132
+ return;
133
+ }
134
+ throw new Error(`Unsupported platform: ${process.platform}`);
135
+ }
136
+
137
+ public static async update(): Promise<UpdateResult> {
138
+ const mode = await Supervisor.detectMode();
139
+ const cmd =
140
+ mode.kind === "dev"
141
+ ? ["bun", "install"]
142
+ : ["bun", "install", "-g", `${NPM_PACKAGE}@latest`];
143
+ const proc = Bun.spawn([...cmd], {
144
+ cwd: mode.kind === "dev" ? mode.packageRoot : undefined,
145
+ stdout: "inherit",
146
+ stderr: "pipe",
147
+ });
148
+ const stderr = await new Response(proc.stderr).text();
149
+ const code = await proc.exited;
150
+ if (code !== 0) {
151
+ return {
152
+ ok: false,
153
+ error: `${cmd.join(" ")} exit ${code}: ${stderr.trim() || "(no stderr)"}`,
154
+ };
155
+ }
156
+ return { ok: true };
157
+ }
158
+
159
+ public static restart(): never {
160
+ process.exit(0);
161
+ }
162
+
163
+ public static async readVersion(): Promise<string> {
164
+ const mode = await Supervisor.detectMode();
165
+ const pkg = await Bun.file(join(mode.packageRoot, "package.json")).json();
166
+ return typeof pkg?.version === "string" ? pkg.version : "?";
167
+ }
168
+
169
+ public static async detectMode(): Promise<Mode> {
170
+ if (Supervisor.cachedMode) {
171
+ return Supervisor.cachedMode;
172
+ }
173
+ const here = await realpath(Bun.fileURLToPath(import.meta.url));
174
+ const packageRoot = await Supervisor.findPackageRoot(dirname(here));
175
+ const hasGit = await Supervisor.pathExists(join(packageRoot, ".git"));
176
+ Supervisor.cachedMode = {
177
+ kind: hasGit ? "dev" : "prod",
178
+ packageRoot,
179
+ pimEntry: join(packageRoot, "bin", "pim.ts"),
180
+ bunPath: process.execPath,
181
+ };
182
+ return Supervisor.cachedMode;
183
+ }
184
+
185
+ public static async appendUpdateConfirm(
186
+ configDir: string,
187
+ entry: UpdateConfirmEntry
188
+ ): Promise<void> {
189
+ const merged = [...(await Supervisor.readUpdateConfirm(configDir)), entry];
190
+ await Fs.writeAtomic(
191
+ Supervisor.updateConfirmPath(configDir),
192
+ JSON.stringify(merged, null, 2)
193
+ );
194
+ }
195
+
196
+ public static async readUpdateConfirm(
197
+ configDir: string
198
+ ): Promise<ReadonlyArray<UpdateConfirmEntry>> {
199
+ const data = await Fs.readJsonOrEmpty<unknown[]>(
200
+ Supervisor.updateConfirmPath(configDir),
201
+ []
202
+ );
203
+ if (!Array.isArray(data)) {
204
+ return [];
205
+ }
206
+ return data.filter(
207
+ (e): e is UpdateConfirmEntry =>
208
+ !!e &&
209
+ typeof e === "object" &&
210
+ typeof (e as UpdateConfirmEntry).chatId === "number" &&
211
+ ((e as UpdateConfirmEntry).threadId === undefined ||
212
+ typeof (e as UpdateConfirmEntry).threadId === "number") &&
213
+ typeof (e as UpdateConfirmEntry).messageId === "number"
214
+ );
215
+ }
216
+
217
+ public static async clearUpdateConfirm(configDir: string): Promise<void> {
218
+ try {
219
+ await unlink(Supervisor.updateConfirmPath(configDir));
220
+ } catch (err) {
221
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
222
+ console.warn(`[update-confirm] unlink failed:`, err);
223
+ }
224
+ }
225
+ }
226
+
227
+ private static cachedMode: Mode | undefined;
228
+
229
+ private static updateConfirmPath(configDir: string): string {
230
+ return join(configDir, CONFIRM_FILE);
231
+ }
232
+
233
+ private static async pathExists(p: string): Promise<boolean> {
234
+ try {
235
+ await stat(p);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ private static async findPackageRoot(start: string): Promise<string> {
243
+ let dir = start;
244
+ for (let i = 0; i < 32; i++) {
245
+ if (await Supervisor.pathExists(join(dir, "package.json"))) {
246
+ return dir;
247
+ }
248
+ const parent = dirname(dir);
249
+ if (parent === dir) {
250
+ break;
251
+ }
252
+ dir = parent;
253
+ }
254
+ throw new Error(`Could not locate package root from ${start}`);
255
+ }
256
+
257
+ private static systemdUnitPath(): string {
258
+ return join(
259
+ homedir(),
260
+ ".config",
261
+ "systemd",
262
+ "user",
263
+ `${UNIT_NAME}.service`
264
+ );
265
+ }
266
+
267
+ private static launchdPlistPath(): string {
268
+ return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
269
+ }
270
+
271
+ private static launchdLogPath(): string {
272
+ return join(homedir(), "Library", "Logs", `${UNIT_NAME}.log`);
273
+ }
274
+
275
+ private static unitPath(mode: Mode): string {
276
+ return `${dirname(mode.bunPath)}:/usr/local/bin:/usr/bin:/bin`;
277
+ }
278
+
279
+ private static systemdUnit(mode: Mode): string {
280
+ return [
281
+ "[Unit]",
282
+ "Description=Pim Telegram daemon",
283
+ "After=network-online.target",
284
+ "Wants=network-online.target",
285
+ "",
286
+ "[Service]",
287
+ "Type=simple",
288
+ `Environment=PATH=${Supervisor.unitPath(mode)}`,
289
+ `ExecStart=${mode.bunPath} ${mode.pimEntry} --mode telegram`,
290
+ "Restart=always",
291
+ "RestartSec=2",
292
+ "",
293
+ "[Install]",
294
+ "WantedBy=default.target",
295
+ "",
296
+ ].join("\n");
297
+ }
298
+
299
+ private static launchdPlist(mode: Mode): string {
300
+ const logPath = Supervisor.launchdLogPath();
301
+ return [
302
+ `<?xml version="1.0" encoding="UTF-8"?>`,
303
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
304
+ `<plist version="1.0">`,
305
+ `<dict>`,
306
+ ` <key>Label</key>`,
307
+ ` <string>${LAUNCHD_LABEL}</string>`,
308
+ ` <key>ProgramArguments</key>`,
309
+ ` <array>`,
310
+ ` <string>${mode.bunPath}</string>`,
311
+ ` <string>${mode.pimEntry}</string>`,
312
+ ` <string>--mode</string>`,
313
+ ` <string>telegram</string>`,
314
+ ` </array>`,
315
+ ` <key>EnvironmentVariables</key>`,
316
+ ` <dict>`,
317
+ ` <key>PATH</key>`,
318
+ ` <string>${Supervisor.unitPath(mode)}</string>`,
319
+ ` </dict>`,
320
+ ` <key>RunAtLoad</key>`,
321
+ ` <true/>`,
322
+ ` <key>KeepAlive</key>`,
323
+ ` <true/>`,
324
+ ` <key>StandardOutPath</key>`,
325
+ ` <string>${logPath}</string>`,
326
+ ` <key>StandardErrorPath</key>`,
327
+ ` <string>${logPath}</string>`,
328
+ `</dict>`,
329
+ `</plist>`,
330
+ ``,
331
+ ].join("\n");
332
+ }
333
+
334
+ private static async lingerEnabled(): Promise<boolean> {
335
+ const proc = Bun.spawn(["loginctl", "show-user", "--property=Linger"], {
336
+ stdout: "pipe",
337
+ stderr: "pipe",
338
+ });
339
+ const code = await proc.exited;
340
+ if (code !== 0) {
341
+ return false;
342
+ }
343
+ const out = await new Response(proc.stdout).text();
344
+ return out.includes("Linger=yes");
345
+ }
346
+
347
+ private static async runOrThrow(cmd: ReadonlyArray<string>): Promise<void> {
348
+ const proc = Bun.spawn([...cmd], { stdout: "inherit", stderr: "pipe" });
349
+ const stderr = await new Response(proc.stderr).text();
350
+ const code = await proc.exited;
351
+ if (code !== 0) {
352
+ throw new Error(
353
+ `${cmd.join(" ")} exit ${code}: ${stderr.trim() || "(no stderr)"}`
354
+ );
355
+ }
356
+ }
357
+ }