niahere 0.2.73 → 0.2.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.73",
3
+ "version": "0.2.75",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,7 +44,7 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.2.97",
47
+ "@anthropic-ai/claude-agent-sdk": "^0.2.119",
48
48
  "@anthropic-ai/sdk": "^0.88.0",
49
49
  "@modelcontextprotocol/sdk": "^1.27.1",
50
50
  "@slack/bolt": "^4.6.0",
@@ -22,15 +22,14 @@ function cleanSentinel(text: string): string {
22
22
  class SlackChannel implements Channel {
23
23
  name = "slack";
24
24
  private app: App | null = null;
25
- private defaultChannelId: string | null = null;
26
25
  private dmUserId: string | null = null;
27
26
  /** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
28
27
  private outboundTs = new Set<string>();
29
28
 
30
29
  async sendMessage(text: string): Promise<void> {
31
30
  if (!this.app) throw new Error("Slack not started");
32
- const target = this.defaultChannelId || this.dmUserId;
33
- if (!target) throw new Error("No Slack recipient — DM the bot first, or set slack_channel_id in config");
31
+ const target = this.dmUserId;
32
+ if (!target) throw new Error("No Slack recipient — set dm_user_id in config");
34
33
  const result = await this.app.client.chat.postMessage({ channel: target, text });
35
34
  if (result.ts) this.outboundTs.add(result.ts);
36
35
  }
@@ -45,8 +44,8 @@ class SlackChannel implements Channel {
45
44
 
46
45
  async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
47
46
  if (!this.app) throw new Error("Slack not started");
48
- const target = this.defaultChannelId || this.dmUserId;
49
- if (!target) throw new Error("No Slack recipient — DM the bot first, or set slack_channel_id in config");
47
+ const target = this.dmUserId;
48
+ if (!target) throw new Error("No Slack recipient — set dm_user_id in config");
50
49
  await this.app.client.filesUploadV2({
51
50
  channel_id: target,
52
51
  file: data,
@@ -61,7 +60,6 @@ class SlackChannel implements Channel {
61
60
 
62
61
  await runMigrations();
63
62
 
64
- this.defaultChannelId = config.channels.slack.channel_id;
65
63
  this.dmUserId = config.channels.slack.dm_user_id;
66
64
 
67
65
  const chats = new Map<string, ChatState>();
@@ -7,21 +7,38 @@ import { log } from "../utils/log";
7
7
  export async function sendCommand(): Promise<void> {
8
8
  const args = process.argv.slice(3);
9
9
  let channel: string | undefined;
10
+ let toChannelId: string | undefined;
11
+ let threadTs: string | undefined;
10
12
  const msgParts: string[] = [];
11
13
  for (let i = 0; i < args.length; i++) {
12
14
  if ((args[i] === "--channel" || args[i] === "-c") && args[i + 1]) {
13
15
  channel = args[++i];
16
+ } else if (args[i] === "--to" && args[i + 1]) {
17
+ toChannelId = args[++i];
18
+ } else if (args[i] === "--thread" && args[i + 1]) {
19
+ threadTs = args[++i];
14
20
  } else {
15
21
  msgParts.push(args[i]);
16
22
  }
17
23
  }
18
24
  const message = msgParts.join(" ");
19
- if (!message) fail("Usage: nia send [-c channel] <message>");
25
+ if (!message) fail("Usage: nia send [-c channel] [--to <slack-channel-id>] [--thread <ts>] <message>");
26
+
27
+ // --to implies slack channel
28
+ if (toChannelId) channel = channel || "slack";
29
+ // --thread requires --to
30
+ if (threadTs && !toChannelId) fail("--thread requires --to <slack-channel-id>");
20
31
 
21
32
  const { sendMessage } = await import("../mcp/tools");
22
33
 
34
+ // Build sourceCtx for targeted channel/thread sends
35
+ const sourceCtx = toChannelId
36
+ ? { slackChannelId: toChannelId, slackThreadTs: threadTs, channel: "slack" as const }
37
+ : undefined;
38
+ const target = toChannelId ? "thread" as const : "auto" as const;
39
+
23
40
  try {
24
- const result = await sendMessage(message, channel);
41
+ const result = await sendMessage(message, channel, undefined, sourceCtx, target);
25
42
  console.log(result);
26
43
  } catch (err) {
27
44
  fail(`Failed to send: ${errMsg(err)}`);
@@ -29,27 +46,43 @@ export async function sendCommand(): Promise<void> {
29
46
  }
30
47
 
31
48
  export function telegramCommand(): void {
32
- const token = process.argv[3];
33
- const chatId = process.argv[4];
49
+ const sub = process.argv[3];
34
50
 
35
- if (!token) {
36
- const config = getConfig();
37
- if (config.channels.telegram.bot_token) {
38
- console.log(`Telegram: configured (...${config.channels.telegram.bot_token.slice(-6)})`);
39
- } else {
40
- console.log("Telegram: not configured");
51
+ if (sub === "setup") {
52
+ const args = process.argv.slice(4);
53
+ let token: string | undefined;
54
+ let chatId: string | undefined;
55
+
56
+ for (const arg of args) {
57
+ if (arg.startsWith("--bot-token=")) token = arg.slice("--bot-token=".length);
58
+ else if (arg.startsWith("--chat-id=")) chatId = arg.slice("--chat-id=".length);
41
59
  }
42
- console.log("\nUsage: nia telegram <bot-token> [chat-id]");
60
+
61
+ if (!token) {
62
+ fail("Usage: nia telegram setup --bot-token=<token> [--chat-id=<id>]");
63
+ }
64
+
65
+ const tg: Record<string, unknown> = { bot_token: token };
66
+ if (chatId) tg.chat_id = Number(chatId);
67
+ updateRawConfig({ channels: { telegram: tg } });
68
+
69
+ console.log(`Telegram bot token saved to ${getPaths().config}`);
70
+ if (chatId) console.log(`Chat ID: ${chatId}`);
71
+ console.log("Run `nia restart` to activate.");
43
72
  return;
44
73
  }
45
74
 
46
- const tg: Record<string, unknown> = { bot_token: token };
47
- if (chatId) tg.chat_id = Number(chatId);
48
- updateRawConfig({ channels: { telegram: tg } });
49
-
50
- console.log(`Telegram bot token saved to ${getPaths().config}`);
51
- if (chatId) console.log(`Chat ID: ${chatId}`);
52
- console.log("Run `nia restart` to activate.");
75
+ // Default: show status
76
+ const config = getConfig();
77
+ if (config.channels.telegram.bot_token) {
78
+ console.log(`Telegram: configured (...${config.channels.telegram.bot_token.slice(-6)})`);
79
+ if (config.channels.telegram.chat_id) {
80
+ console.log(` Chat ID: ${config.channels.telegram.chat_id}`);
81
+ }
82
+ } else {
83
+ console.log("Telegram: not configured");
84
+ }
85
+ console.log("\nUsage: nia telegram setup --bot-token=<token> [--chat-id=<id>]");
53
86
  }
54
87
 
55
88
  /** Call Slack auth.test to enrich config with workspace/bot info. */
@@ -80,60 +113,79 @@ export async function enrichSlackConfig(botToken: string): Promise<Record<string
80
113
  }
81
114
 
82
115
  export async function slackCommand(): Promise<void> {
83
- const botToken = process.argv[3];
84
- const appToken = process.argv[4];
85
-
86
- if (!botToken) {
87
- const config = getConfig();
88
- if (config.channels.slack.bot_token) {
89
- console.log(`Slack: configured (...${config.channels.slack.bot_token.slice(-6)})`);
90
- if (config.channels.slack.workspace) {
91
- console.log(` Workspace: ${config.channels.slack.workspace} (${config.channels.slack.workspace_url})`);
92
- console.log(` Bot: @${config.channels.slack.bot_name} (${config.channels.slack.bot_user_id})`);
93
- }
94
- // Verify auth is working
95
- try {
96
- const resp = await fetch("https://slack.com/api/auth.test", {
97
- headers: { Authorization: `Bearer ${config.channels.slack.bot_token}` },
98
- });
99
- const data = (await resp.json()) as Record<string, unknown>;
100
- if (data.ok) {
101
- console.log(` Auth: ${ICON_PASS} valid`);
102
- // Backfill workspace info if missing
103
- if (!config.channels.slack.workspace) {
104
- const enriched = await enrichSlackConfig(config.channels.slack.bot_token);
105
- if (Object.keys(enriched).length > 0) {
106
- updateRawConfig({ channels: { slack: enriched } });
107
- console.log(" (workspace info backfilled)");
108
- }
109
- }
110
- } else {
111
- console.log(` Auth: ${ICON_FAIL} ${data.error}`);
112
- }
113
- } catch (err) {
114
- console.log(` Auth: ${ICON_FAIL} could not reach Slack API`);
115
- }
116
- } else {
117
- console.log("Slack: not configured");
116
+ const sub = process.argv[3];
117
+
118
+ if (sub === "setup") {
119
+ const args = process.argv.slice(4);
120
+ let botToken: string | undefined;
121
+ let appToken: string | undefined;
122
+ let dmUserId: string | undefined;
123
+
124
+ for (const arg of args) {
125
+ if (arg.startsWith("--bot-token=")) botToken = arg.slice("--bot-token=".length);
126
+ else if (arg.startsWith("--app-token=")) appToken = arg.slice("--app-token=".length);
127
+ else if (arg.startsWith("--dm-user-id=")) dmUserId = arg.slice("--dm-user-id=".length);
118
128
  }
119
- console.log("\nUsage: nia slack <bot-token> <app-token> [channel-id]");
120
- console.log("\nCreate a Slack app: https://api.slack.com/apps (use defaults/channels/slack-manifest.json)");
121
- return;
122
- }
123
129
 
124
- if (!appToken) fail("App token required. Usage: nia slack <bot-token> <app-token> [channel-id]");
130
+ if (!botToken || !appToken) {
131
+ fail("Usage: nia slack setup --bot-token=xoxb-... --app-token=xapp-... [--dm-user-id=U...]");
132
+ }
125
133
 
126
- const sl: Record<string, unknown> = { bot_token: botToken, app_token: appToken };
127
- const channelId = process.argv[5];
128
- if (channelId) sl.channel_id = channelId;
134
+ if (!botToken.startsWith("xoxb-")) {
135
+ fail(`Invalid bot token — must start with "xoxb-" (got "${botToken.slice(0, 10)}...")`);
136
+ }
137
+ if (!appToken.startsWith("xapp-")) {
138
+ fail(`Invalid app token — must start with "xapp-" (got "${appToken.slice(0, 10)}...")`);
139
+ }
140
+
141
+ const sl: Record<string, unknown> = { bot_token: botToken, app_token: appToken };
142
+ if (dmUserId) sl.dm_user_id = dmUserId;
129
143
 
130
- // Enrich with workspace/bot info from auth.test
131
- const enriched = await enrichSlackConfig(botToken);
132
- Object.assign(sl, enriched);
144
+ const enriched = await enrichSlackConfig(botToken);
145
+ Object.assign(sl, enriched);
133
146
 
134
- updateRawConfig({ channels: { slack: sl } });
147
+ updateRawConfig({ channels: { slack: sl } });
135
148
 
136
- console.log(`Slack tokens saved to ${getPaths().config}`);
137
- if (channelId) console.log(`Channel ID: ${channelId}`);
138
- console.log("Run `nia restart` to activate.");
149
+ console.log(`Slack tokens saved to ${getPaths().config}`);
150
+ if (dmUserId) console.log(`DM user: ${dmUserId}`);
151
+ console.log("Run `nia restart` to activate.");
152
+ return;
153
+ }
154
+
155
+ // Default: show status
156
+ const config = getConfig();
157
+ if (config.channels.slack.bot_token) {
158
+ console.log(`Slack: configured (...${config.channels.slack.bot_token.slice(-6)})`);
159
+ if (config.channels.slack.workspace) {
160
+ console.log(` Workspace: ${config.channels.slack.workspace} (${config.channels.slack.workspace_url})`);
161
+ console.log(` Bot: @${config.channels.slack.bot_name} (${config.channels.slack.bot_user_id})`);
162
+ }
163
+ if (config.channels.slack.dm_user_id) {
164
+ console.log(` DM user: ${config.channels.slack.dm_user_id}`);
165
+ }
166
+ try {
167
+ const resp = await fetch("https://slack.com/api/auth.test", {
168
+ headers: { Authorization: `Bearer ${config.channels.slack.bot_token}` },
169
+ });
170
+ const data = (await resp.json()) as Record<string, unknown>;
171
+ if (data.ok) {
172
+ console.log(` Auth: ${ICON_PASS} valid`);
173
+ if (!config.channels.slack.workspace) {
174
+ const enriched = await enrichSlackConfig(config.channels.slack.bot_token);
175
+ if (Object.keys(enriched).length > 0) {
176
+ updateRawConfig({ channels: { slack: enriched } });
177
+ console.log(" (workspace info backfilled)");
178
+ }
179
+ }
180
+ } else {
181
+ console.log(` Auth: ${ICON_FAIL} ${data.error}`);
182
+ }
183
+ } catch (err) {
184
+ console.log(` Auth: ${ICON_FAIL} could not reach Slack API`);
185
+ }
186
+ } else {
187
+ console.log("Slack: not configured");
188
+ }
189
+ console.log("\nUsage: nia slack setup --bot-token=xoxb-... --app-token=xapp-... [--dm-user-id=U...]");
190
+ console.log("\nCreate a Slack app: https://api.slack.com/apps (use defaults/channels/slack-manifest.json)");
139
191
  }
package/src/cli/index.ts CHANGED
@@ -572,7 +572,7 @@ Chat:
572
572
  chat [-c] [-r] [--employee|--agent|--job name] Interactive chat
573
573
  run <prompt> One-shot execution
574
574
  history [room] Recent messages
575
- send [-c ch] <msg> Send a message via channel
575
+ send [-c ch] [--to C --thread T] <msg> Send message (DM, channel, or thread)
576
576
 
577
577
  Jobs:
578
578
  job <sub> Manage jobs (list|add|update|remove|run|...)
@@ -587,8 +587,8 @@ Persona:
587
587
  Channels:
588
588
  channels [on|off] Toggle channels
589
589
  watch <sub> Manage Slack watch channels
590
- telegram <token> Configure telegram
591
- slack <bot> <app> Configure slack
590
+ telegram [setup] Configure telegram
591
+ slack [setup] Configure slack
592
592
 
593
593
  System:
594
594
  config <set|get|list> Manage config values
@@ -168,7 +168,7 @@ export async function runInit(): Promise<void> {
168
168
  // Slack
169
169
  let slackBotToken = "";
170
170
  let slackAppToken = "";
171
- let slackChannelId = (exSl.channel_id as string) || "";
171
+ let slackDmUserId = (exSl.dm_user_id as string) || "";
172
172
 
173
173
  const existingSlackBot = (exSl.bot_token as string) || "";
174
174
 
@@ -182,12 +182,12 @@ export async function runInit(): Promise<void> {
182
182
  const appInput = await ask(rl, "App token (xapp-...)", "");
183
183
  slackAppToken = appInput || existingSlackApp;
184
184
  if (slackBotToken && slackAppToken) {
185
- slackChannelId = await ask(rl, "Default channel ID for outbound messages (optional)", slackChannelId);
185
+ slackDmUserId = await ask(rl, "DM user ID for outbound messages (U...)", slackDmUserId);
186
186
  }
187
187
  } else {
188
188
  slackBotToken = existingSlackBot;
189
189
  slackAppToken = (exSl.app_token as string) || "";
190
- slackChannelId = (exSl.channel_id as string) || "";
190
+ slackDmUserId = (exSl.dm_user_id as string) || "";
191
191
  }
192
192
  } else {
193
193
  const setupSlack = await ask(rl, "\nSet up Slack? (y/n)", "n");
@@ -210,7 +210,7 @@ export async function runInit(): Promise<void> {
210
210
  slackAppToken = await ask(rl, "App token (xapp-...)", "");
211
211
 
212
212
  if (slackBotToken && slackAppToken) {
213
- slackChannelId = await ask(rl, "Default channel ID for outbound messages (optional)", "");
213
+ slackDmUserId = await ask(rl, "DM user ID for outbound messages (U...)", "");
214
214
  }
215
215
  }
216
216
  }
@@ -421,7 +421,7 @@ export async function runInit(): Promise<void> {
421
421
  }
422
422
  if (slackBotToken && slackAppToken) {
423
423
  const sl: Record<string, unknown> = { bot_token: slackBotToken, app_token: slackAppToken };
424
- if (slackChannelId) sl.channel_id = slackChannelId;
424
+ if (slackDmUserId) sl.dm_user_id = slackDmUserId;
425
425
  // Enrich with workspace/bot info from auth.test
426
426
  const enriched = await enrichSlackConfig(slackBotToken);
427
427
  Object.assign(sl, enriched);
package/src/core/alive.ts CHANGED
@@ -100,7 +100,7 @@ async function notifyUser(message: string): Promise<void> {
100
100
  }
101
101
 
102
102
  const slToken = config.channels.slack.bot_token;
103
- const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
103
+ const slRecipient = config.channels.slack.dm_user_id;
104
104
  if (slToken && slRecipient) {
105
105
  try {
106
106
  const resp = await fetch("https://slack.com/api/chat.postMessage", {
package/src/mcp/tools.ts CHANGED
@@ -171,9 +171,9 @@ async function sendDirect(target: string, text: string): Promise<void> {
171
171
 
172
172
  if (target === "slack") {
173
173
  const token = config.channels.slack.bot_token;
174
- const recipient = config.channels.slack.channel_id || config.channels.slack.dm_user_id;
174
+ const recipient = config.channels.slack.dm_user_id;
175
175
  if (!token) throw new Error("Slack not configured (no bot token)");
176
- if (!recipient) throw new Error("No Slack recipient — DM the bot first, or set slack_channel_id in config");
176
+ if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
177
177
  const { App } = await import("@slack/bolt");
178
178
  const app = new App({ token, signingSecret: "unused" });
179
179
  await app.client.chat.postMessage({ token, channel: recipient, text });
@@ -205,9 +205,9 @@ async function sendMediaDirect(target: string, data: Buffer, mimeType: string, f
205
205
 
206
206
  if (target === "slack") {
207
207
  const token = config.channels.slack.bot_token;
208
- const recipient = config.channels.slack.channel_id || config.channels.slack.dm_user_id;
208
+ const recipient = config.channels.slack.dm_user_id;
209
209
  if (!token) throw new Error("Slack not configured (no bot token)");
210
- if (!recipient) throw new Error("No Slack recipient — DM the bot first, or set slack_channel_id in config");
210
+ if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
211
211
  const { App } = await import("@slack/bolt");
212
212
  const app = new App({ token, signingSecret: "unused" });
213
213
  await app.client.filesUploadV2({
@@ -245,11 +245,8 @@ export async function sendMessage(text: string, channelName?: string, mediaPath?
245
245
  // Replying in-thread: use the source session's room prefix
246
246
  roomPrefix = sourceCtx.room.replace(/-\d+$/, "");
247
247
  } else {
248
- const channelId = config.channels.slack.channel_id;
249
248
  const dmUserId = config.channels.slack.dm_user_id;
250
- if (channelId) {
251
- roomPrefix = `slack-${channelId}`;
252
- } else if (dmUserId) {
249
+ if (dmUserId) {
253
250
  roomPrefix = `slack-dm-${dmUserId}`;
254
251
  }
255
252
  }
@@ -100,8 +100,7 @@ Config reference:
100
100
  - `channels.telegram.open` — if true, anyone can message the bot
101
101
  - `channels.slack.bot_token` — Slack bot token (xoxb-...)
102
102
  - `channels.slack.app_token` — Slack app token (xapp-...)
103
- - `channels.slack.channel_id` — default Slack channel for outbound
104
- - `channels.slack.dm_user_id` — auto-registered DM user
103
+ - `channels.slack.dm_user_id` — Slack user ID for DM-based outbound messages
105
104
  - `channels.slack.watch` — per-channel proactive monitoring. Keys use `channel_id#channel_name` format. The `behavior` field is optional and has three forms: (1) omitted — loads `~/.niahere/watches/<channel_name>/behavior.md`; (2) single word like `deal-monitor` — loads `~/.niahere/watches/deal-monitor/behavior.md` (dir-per-watch, like agents); (3) inline prose. File-backed watches hot-reload via mtime tracking, no restart needed.
106
105
  {{slackWatch}}
107
106
 
@@ -19,7 +19,6 @@ export interface SlackWatchChannel {
19
19
  export interface SlackConfig {
20
20
  bot_token: string | null;
21
21
  app_token: string | null;
22
- channel_id: string | null;
23
22
  dm_user_id: string | null;
24
23
  bot_user_id: string | null;
25
24
  bot_name: string | null;
@@ -23,7 +23,6 @@ const DEFAULTS: Config = {
23
23
  slack: {
24
24
  bot_token: null,
25
25
  app_token: null,
26
- channel_id: null,
27
26
  dm_user_id: null,
28
27
  bot_user_id: null,
29
28
  bot_name: null,
@@ -124,9 +123,12 @@ export function loadConfig(): Config {
124
123
 
125
124
  const slAppToken = process.env.SLACK_APP_TOKEN || (typeof chSl.app_token === "string" ? chSl.app_token : null);
126
125
 
127
- const slChannelId = process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
128
-
129
- const slDmUserId = typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null;
126
+ // Legacy: channel_id was removed in favor of dm_user_id. Fall back to channel_id if dm_user_id is not set.
127
+ const legacyChannelId = process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
128
+ const slDmUserId =
129
+ process.env.SLACK_DM_USER_ID ||
130
+ (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null) ||
131
+ legacyChannelId;
130
132
 
131
133
  const slBotUserId = typeof chSl.bot_user_id === "string" ? chSl.bot_user_id : null;
132
134
  const slBotName = typeof chSl.bot_name === "string" ? chSl.bot_name : null;
@@ -164,7 +166,6 @@ export function loadConfig(): Config {
164
166
  slack: {
165
167
  bot_token: slBotToken,
166
168
  app_token: slAppToken,
167
- channel_id: slChannelId,
168
169
  dm_user_id: slDmUserId,
169
170
  bot_user_id: slBotUserId,
170
171
  bot_name: slBotName,
package/src/utils/pid.ts CHANGED
@@ -3,23 +3,50 @@ import { dirname } from "path";
3
3
  import { getPaths } from "./paths";
4
4
  import { log } from "./log";
5
5
 
6
+ type PidEntry = { pid: number; lstart: string };
7
+
8
+ function getLstart(pid: number): string {
9
+ try {
10
+ const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)]);
11
+ return new TextDecoder().decode(result.stdout).trim();
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
6
17
  export function writePid(pid: number): void {
7
18
  const { pid: pidPath } = getPaths();
19
+ const lstart = getLstart(pid);
20
+ if (!lstart) {
21
+ log.warn({ pid }, "could not capture pid identity (ps returned nothing)");
22
+ }
8
23
  mkdirSync(dirname(pidPath), { recursive: true });
9
- writeFileSync(pidPath, String(pid));
24
+ writeFileSync(pidPath, JSON.stringify({ pid, lstart }));
10
25
  }
11
26
 
12
- export function readPid(): number | null {
27
+ function readEntry(): PidEntry | null {
13
28
  const { pid: pidPath } = getPaths();
14
29
  if (!existsSync(pidPath)) return null;
15
30
 
16
31
  try {
17
- return parseInt(readFileSync(pidPath, "utf8").trim(), 10);
32
+ const raw = readFileSync(pidPath, "utf8").trim();
33
+ if (/^\d+$/.test(raw)) {
34
+ return { pid: parseInt(raw, 10), lstart: "" };
35
+ }
36
+ const parsed = JSON.parse(raw);
37
+ if (typeof parsed?.pid === "number" && typeof parsed?.lstart === "string") {
38
+ return parsed as PidEntry;
39
+ }
40
+ return null;
18
41
  } catch {
19
42
  return null;
20
43
  }
21
44
  }
22
45
 
46
+ export function readPid(): number | null {
47
+ return readEntry()?.pid ?? null;
48
+ }
49
+
23
50
  export function removePid(): void {
24
51
  const { pid: pidPath } = getPaths();
25
52
  try {
@@ -30,15 +57,22 @@ export function removePid(): void {
30
57
  }
31
58
 
32
59
  export function isRunning(): boolean {
33
- const pid = readPid();
34
- if (pid === null) return false;
60
+ const entry = readEntry();
61
+ if (entry === null) return false;
35
62
 
36
- try {
37
- process.kill(pid, 0);
38
- return true;
39
- } catch {
40
- log.warn({ stalePid: pid }, "removing stale pid file (process not running)");
63
+ const currentLstart = getLstart(entry.pid);
64
+ if (!currentLstart) {
65
+ log.warn({ stalePid: entry.pid }, "removing stale pid file (process not running)");
66
+ removePid();
67
+ return false;
68
+ }
69
+ if (entry.lstart && currentLstart !== entry.lstart) {
70
+ log.warn(
71
+ { stalePid: entry.pid, recorded: entry.lstart, current: currentLstart },
72
+ "removing stale pid file (process identity mismatch)",
73
+ );
41
74
  removePid();
42
75
  return false;
43
76
  }
77
+ return true;
44
78
  }