niahere 0.2.76 → 0.2.77

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.76",
3
+ "version": "0.2.77",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -23,6 +23,7 @@ import { truncate, formatToolUse } from "../utils/format-activity";
23
23
  import { finalizeSession, cancelPending } from "../core/finalizer";
24
24
  import { log } from "../utils/log";
25
25
  import { isRetryableApiError, sleep } from "../utils/retry";
26
+ import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
26
27
 
27
28
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
28
29
  const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
@@ -272,9 +273,20 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
272
273
  queryHandle.close();
273
274
  queryHandle = null;
274
275
  }
276
+ unregisterActiveHandle(room);
275
277
  alive = false;
276
278
  }
277
279
 
280
+ async function abortActiveQuery(reason: string) {
281
+ const activePending = pending;
282
+ pending = null;
283
+ if (activePending) {
284
+ activePending.reject(new Error(reason));
285
+ }
286
+ teardown();
287
+ await ActiveEngine.unregister(room).catch(() => {});
288
+ }
289
+
278
290
  function startQuery() {
279
291
  stream = new MessageStream();
280
292
  alive = true;
@@ -309,6 +321,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
309
321
  prompt: stream as any,
310
322
  options: options as any,
311
323
  });
324
+ registerActiveHandle(room, abortActiveQuery);
312
325
 
313
326
  // Background consumer — runs for the lifetime of the query
314
327
  (async () => {
@@ -523,6 +536,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
523
536
  }
524
537
  } finally {
525
538
  clearLongRunningTimer();
539
+ unregisterActiveHandle(room);
526
540
  alive = false;
527
541
  stream = null;
528
542
  queryHandle = null;
@@ -596,8 +610,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
596
610
  log.error({ err, room }, "finalization enqueue failed during close");
597
611
  }
598
612
  }
599
- teardown();
600
- await ActiveEngine.unregister(room).catch(() => {});
613
+ await abortActiveQuery("chat engine closed");
601
614
  },
602
615
  };
603
616
  }
package/src/cli/index.ts CHANGED
@@ -116,8 +116,8 @@ switch (command) {
116
116
  const stopGuard = parseGuardFlags(process.argv.slice(3));
117
117
  if (!(await guardActiveEngines("stop", stopGuard))) process.exit(1);
118
118
  const { unregisterService } = await import("../commands/service");
119
- await unregisterService();
120
- stopDaemon();
119
+ await unregisterService({ force: stopGuard.force });
120
+ stopDaemon({ force: stopGuard.force });
121
121
  console.log("nia stopped");
122
122
  break;
123
123
  }
@@ -138,9 +138,9 @@ switch (command) {
138
138
  if (!(await guardActiveEngines("restart", restartGuard))) process.exit(1);
139
139
  const { isServiceInstalled, restartService } = await import("../commands/service");
140
140
  if (isServiceInstalled()) {
141
- await restartService();
141
+ await restartService({ force: restartGuard.force });
142
142
  } else {
143
- stopDaemon();
143
+ stopDaemon({ force: restartGuard.force });
144
144
  startDaemon();
145
145
  }
146
146
  const restartPid = readPid();
@@ -536,9 +536,9 @@ switch (command) {
536
536
  console.log("Restarting daemon...");
537
537
  const { isServiceInstalled, restartService } = await import("../commands/service");
538
538
  if (isServiceInstalled()) {
539
- await restartService();
539
+ await restartService({ force: updateGuard.force });
540
540
  } else {
541
- stopDaemon();
541
+ stopDaemon({ force: updateGuard.force });
542
542
  startDaemon();
543
543
  }
544
544
  console.log("Restarted.");
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { getPaths } from "../utils/paths";
5
5
  import { findDaemonPids } from "../core/daemon";
6
+ import { clearForceShutdownRequest, requestForceShutdown } from "../core/force-shutdown";
6
7
 
7
8
  const PLIST_NAME = "com.niahere.agent";
8
9
  const SYSTEMD_UNIT = "niahere.service";
@@ -173,7 +174,8 @@ export async function registerService(): Promise<void> {
173
174
  // Windows/other: no-op, daemon still works via startDaemon()
174
175
  }
175
176
 
176
- export async function unregisterService(): Promise<void> {
177
+ export async function unregisterService(opts: { force?: boolean } = {}): Promise<void> {
178
+ if (opts.force) requestForceShutdown(findDaemonPids());
177
179
  if (process.platform === "darwin") {
178
180
  await uninstallLaunchd();
179
181
  } else if (process.platform === "linux") {
@@ -188,7 +190,9 @@ export function isServiceInstalled(): boolean {
188
190
  }
189
191
 
190
192
  /** Restart via service manager. Waits for old process to fully exit before starting new one. */
191
- export async function restartService(): Promise<void> {
193
+ export async function restartService(opts: { force?: boolean } = {}): Promise<void> {
194
+ if (opts.force) requestForceShutdown(findDaemonPids());
195
+ const waitMs = opts.force ? 30_000 : 310_000;
192
196
  if (process.platform === "darwin") {
193
197
  const path = plistPath();
194
198
  // Unload — sends SIGTERM and disables KeepAlive respawn
@@ -198,7 +202,8 @@ export async function restartService(): Promise<void> {
198
202
  console.error(` warning: launchctl unload failed: ${stderr.trim()}`);
199
203
  }
200
204
  // Wait for old daemon to fully exit (graceful shutdown may take time for active engines)
201
- await waitForDaemonExit(310_000);
205
+ await waitForDaemonExit(waitMs);
206
+ if (opts.force) clearForceShutdownRequest();
202
207
  // Load — starts a fresh single instance
203
208
  const load = Bun.spawn(["launchctl", "load", path], { stdout: "pipe", stderr: "pipe" });
204
209
  if (await load.exited !== 0) {
@@ -209,6 +214,8 @@ export async function restartService(): Promise<void> {
209
214
  // systemd restart handles stop-wait-start internally
210
215
  const restart = Bun.spawn(["systemctl", "--user", "restart", SYSTEMD_UNIT], { stdout: "pipe", stderr: "pipe" });
211
216
  await restart.exited;
217
+ if (opts.force) await waitForDaemonExit(waitMs);
218
+ if (opts.force) clearForceShutdownRequest();
212
219
  }
213
220
  }
214
221
 
@@ -0,0 +1,31 @@
1
+ import { log } from "../utils/log";
2
+
3
+ type CloseHandle = (reason: string) => void | Promise<void>;
4
+
5
+ const handles = new Map<string, CloseHandle>();
6
+
7
+ export function registerActiveHandle(room: string, close: CloseHandle): void {
8
+ handles.set(room, close);
9
+ }
10
+
11
+ export function unregisterActiveHandle(room: string): void {
12
+ handles.delete(room);
13
+ }
14
+
15
+ export function activeHandleCount(): number {
16
+ return handles.size;
17
+ }
18
+
19
+ export async function closeAllActiveHandles(reason: string): Promise<number> {
20
+ const entries = [...handles.entries()];
21
+ for (const [room, close] of entries) {
22
+ try {
23
+ await close(reason);
24
+ } catch (err) {
25
+ log.warn({ err, room }, "failed to close active handle");
26
+ } finally {
27
+ handles.delete(room);
28
+ }
29
+ }
30
+ return entries.length;
31
+ }
@@ -15,6 +15,8 @@ import { startAlive, stopAlive } from "./alive";
15
15
  import { createNiaMcpServer } from "../mcp/server";
16
16
  import { setMcpFactory } from "../mcp";
17
17
  import { processPending, cleanupOldRequests } from "./finalizer";
18
+ import { closeAllActiveHandles } from "./active-handles";
19
+ import { clearForceShutdownRequest, consumeForceShutdownRequest, requestForceShutdown } from "./force-shutdown";
18
20
 
19
21
  export { isRunning, readPid, removePid, writePid };
20
22
 
@@ -46,8 +48,11 @@ export function startDaemon(): number {
46
48
  return pid;
47
49
  }
48
50
 
49
- export function stopDaemon(): boolean {
51
+ export function stopDaemon(opts: { force?: boolean } = {}): boolean {
50
52
  const pidfilePid = readPid();
53
+ if (opts.force) {
54
+ requestForceShutdown([...(pidfilePid ? [pidfilePid] : []), ...findDaemonPids()]);
55
+ }
51
56
  removePid();
52
57
 
53
58
  // Kill all daemon processes — pidfile PID plus any orphans.
@@ -55,7 +60,8 @@ export function stopDaemon(): boolean {
55
60
  if (killed === 0 && pidfilePid === null) return false;
56
61
 
57
62
  // Wait for processes to finish (up to 5 min for active engines, then SIGKILL)
58
- waitForExit(310_000);
63
+ waitForExit(opts.force ? 30_000 : 310_000);
64
+ if (opts.force) clearForceShutdownRequest();
59
65
  return true;
60
66
  }
61
67
 
@@ -361,16 +367,21 @@ export async function runDaemon(): Promise<void> {
361
367
  const shutdown = async () => {
362
368
  if (shuttingDown) return;
363
369
  shuttingDown = true;
370
+ const force = consumeForceShutdownRequest(process.pid);
364
371
 
365
- log.info("shutting down...");
372
+ log.info({ force }, "shutting down...");
366
373
 
367
374
  stopAlive();
368
375
  stopScheduler();
369
376
  await stopChannels(channels);
370
377
 
371
378
  try {
379
+ if (force) {
380
+ const closed = await closeAllActiveHandles("force shutdown");
381
+ log.warn({ closed }, "force closed active engines");
382
+ }
372
383
  const engines = await ActiveEngine.list();
373
- if (engines.length > 0) {
384
+ if (engines.length > 0 && !force) {
374
385
  log.info({ count: engines.length }, "waiting for active engines to finish");
375
386
  const deadline = Date.now() + 300_000;
376
387
  while (Date.now() < deadline) {
@@ -378,8 +389,8 @@ export async function runDaemon(): Promise<void> {
378
389
  if (remaining.length === 0) break;
379
390
  await new Promise((r) => setTimeout(r, 1000));
380
391
  }
381
- await ActiveEngine.clearAll();
382
392
  }
393
+ if (engines.length > 0 || force) await ActiveEngine.clearAll();
383
394
  } catch {
384
395
  // postgres may be gone
385
396
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Default: warn and refuse.
5
5
  * --wait <minutes>: poll until engines clear, then proceed. Times out with error.
6
- * --force: skip the check entirely.
6
+ * --force: skip the guard and ask the daemon to close active handles.
7
7
  */
8
8
 
9
9
  import { ActiveEngine } from "../db/models";
@@ -0,0 +1,52 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { getNiaHome } from "../utils/paths";
4
+
5
+ const FORCE_MARKER_MAX_AGE_MS = 10 * 60 * 1000;
6
+
7
+ interface ForceShutdownMarker {
8
+ createdAt: number;
9
+ pids: number[];
10
+ }
11
+
12
+ function markerPath(): string {
13
+ return join(getNiaHome(), "tmp", "force-shutdown.json");
14
+ }
15
+
16
+ export function requestForceShutdown(pids: number[] = []): void {
17
+ const path = markerPath();
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ const uniquePids = [...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0))];
20
+ const marker: ForceShutdownMarker = { createdAt: Date.now(), pids: uniquePids };
21
+ writeFileSync(path, JSON.stringify(marker));
22
+ }
23
+
24
+ export function clearForceShutdownRequest(): void {
25
+ try {
26
+ unlinkSync(markerPath());
27
+ } catch {
28
+ // already gone
29
+ }
30
+ }
31
+
32
+ export function consumeForceShutdownRequest(pid: number = process.pid): boolean {
33
+ const path = markerPath();
34
+ if (!existsSync(path)) return false;
35
+
36
+ let marker: ForceShutdownMarker;
37
+ try {
38
+ marker = JSON.parse(readFileSync(path, "utf8"));
39
+ } catch {
40
+ clearForceShutdownRequest();
41
+ return false;
42
+ }
43
+
44
+ if (Date.now() - marker.createdAt > FORCE_MARKER_MAX_AGE_MS) {
45
+ clearForceShutdownRequest();
46
+ return false;
47
+ }
48
+
49
+ const applies = marker.pids.length === 0 || marker.pids.includes(pid);
50
+ if (applies) clearForceShutdownRequest();
51
+ return applies;
52
+ }
@@ -17,6 +17,7 @@ import { ActiveEngine } from "../db/models";
17
17
  import { getPaths } from "../utils/paths";
18
18
  import { log } from "../utils/log";
19
19
  import { isRetryableApiError, sleep } from "../utils/retry";
20
+ import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
20
21
 
21
22
  export type ActivityCallback = (line: string) => void;
22
23
 
@@ -98,6 +99,7 @@ export async function runJobWithClaude(
98
99
  onActivity?: ActivityCallback,
99
100
  model?: string,
100
101
  sourceCtx?: McpSourceContext,
102
+ activeRoom?: string,
101
103
  ): Promise<RunnerOutput> {
102
104
  const sessionId = randomUUID();
103
105
 
@@ -131,6 +133,13 @@ export async function runJobWithClaude(
131
133
  prompt: singleMessage() as any,
132
134
  options: options as any,
133
135
  });
136
+ let abortReason: string | null = null;
137
+ if (activeRoom) {
138
+ registerActiveHandle(activeRoom, (reason) => {
139
+ abortReason = reason;
140
+ handle.close();
141
+ });
142
+ }
134
143
 
135
144
  let agentText = "";
136
145
  let actualSessionId = sessionId;
@@ -214,8 +223,28 @@ export async function runJobWithClaude(
214
223
  }
215
224
  }
216
225
  }
226
+ } catch (err) {
227
+ if (abortReason) {
228
+ return {
229
+ agentText: "",
230
+ sessionId: actualSessionId,
231
+ terminalReason: "aborted",
232
+ error: abortReason,
233
+ };
234
+ }
235
+ throw err;
217
236
  } finally {
218
237
  handle.close();
238
+ if (activeRoom) unregisterActiveHandle(activeRoom);
239
+ }
240
+
241
+ if (abortReason) {
242
+ return {
243
+ agentText: "",
244
+ sessionId: actualSessionId,
245
+ terminalReason: "aborted",
246
+ error: abortReason,
247
+ };
219
248
  }
220
249
 
221
250
  return { agentText, sessionId: actualSessionId, terminalReason };
@@ -243,7 +272,7 @@ export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
243
272
  await ActiveEngine.register(room, "system").catch(() => {});
244
273
  try {
245
274
  const systemPrompt = opts.systemPrompt || buildSystemPrompt("job");
246
- const output = await runJobWithClaude(systemPrompt, opts.prompt, homedir());
275
+ const output = await runJobWithClaude(systemPrompt, opts.prompt, homedir(), undefined, undefined, undefined, room);
247
276
  if (output.error) {
248
277
  log.error({ task: opts.name, error: output.error }, "task failed");
249
278
  } else {
@@ -297,11 +326,13 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
297
326
  const config = getConfig();
298
327
  const timestamp = new Date().toISOString();
299
328
  const startMs = performance.now();
329
+ const room = `job/${job.name}`;
300
330
 
301
331
  // Update state: running
302
332
  const state: Record<string, JobState> = { ...readState() };
303
333
  state[job.name] = { lastRun: timestamp, status: "running", duration_ms: 0 };
304
334
  writeState(state);
335
+ await ActiveEngine.register(room, "job").catch(() => {});
305
336
 
306
337
  try {
307
338
  let cwd = homedir();
@@ -352,7 +383,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
352
383
  const fullPrompt = `${systemPrompt}\n\n---\n\n${jobPrompt}`;
353
384
  output = await runJobWithCodex(fullPrompt, cwd, resolvedModel);
354
385
  } else {
355
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
386
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx, room);
356
387
 
357
388
  for (let attempt = 0; attempt < MAX_API_RETRIES && output.error && isRetryableApiError(output.error); attempt++) {
358
389
  const delay = RETRY_DELAYS[attempt] ?? 8_000;
@@ -361,7 +392,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
361
392
  "retrying after transient API error",
362
393
  );
363
394
  await sleep(delay);
364
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
395
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx, room);
365
396
  }
366
397
  }
367
398
 
@@ -435,5 +466,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
435
466
  writeState(freshState);
436
467
 
437
468
  return result;
469
+ } finally {
470
+ await ActiveEngine.unregister(room).catch(() => {});
438
471
  }
439
472
  }
@@ -9,6 +9,13 @@ You are running as part of the assistant daemon.
9
9
  - Current date (authoritative): {{currentDate}}
10
10
  - Current time: {{currentTime}}
11
11
 
12
+ ## Runtime OS
13
+
14
+ - OS: {{osName}} ({{osType}} {{osRelease}})
15
+ - Platform: {{osPlatform}}
16
+ - Architecture: {{osArch}}
17
+ - Shell: {{shell}}
18
+
12
19
  When writing calendar digests, standups, reminders, or any dated message, preserve the weekday/date pairing from the authoritative current date above. If a weekday/date mismatch would matter, verify with the system date or source calendar before sending.
13
20
 
14
21
  ## Nia CLI
@@ -3,6 +3,7 @@ import { join, resolve } from "path";
3
3
  import { getPaths } from "../utils/paths";
4
4
  import { getConfig } from "../utils/config";
5
5
  import { formatPromptDate, formatPromptDateTime } from "../utils/time";
6
+ import { getRuntimeOsInfo } from "../utils/os";
6
7
  import type { Mode } from "../types";
7
8
 
8
9
  const PROMPTS_DIR = resolve(import.meta.dir);
@@ -21,6 +22,7 @@ export function getEnvironmentPrompt(): string {
21
22
  const paths = getPaths();
22
23
  const config = getConfig();
23
24
  const now = new Date();
25
+ const osInfo = getRuntimeOsInfo();
24
26
 
25
27
  // Build watch channel summary if Slack is configured with watch channels
26
28
  let slackWatch = "";
@@ -37,6 +39,12 @@ export function getEnvironmentPrompt(): string {
37
39
  timezone: config.timezone,
38
40
  currentDate: formatPromptDate(now, config.timezone),
39
41
  currentTime: formatPromptDateTime(now, config.timezone),
42
+ osName: osInfo.osName,
43
+ osType: osInfo.osType,
44
+ osRelease: osInfo.osRelease,
45
+ osPlatform: osInfo.osPlatform,
46
+ osArch: osInfo.osArch,
47
+ shell: osInfo.shell,
40
48
  activeStart: config.activeHours.start,
41
49
  activeEnd: config.activeHours.end,
42
50
  model: config.model,
@@ -0,0 +1,33 @@
1
+ import { arch, platform, release, type } from "os";
2
+
3
+ function shellName(): string {
4
+ const raw = process.env.SHELL || process.env.ComSpec || "";
5
+ if (!raw) return "unknown";
6
+ const normalized = raw.replace(/\\/g, "/");
7
+ return normalized.split("/").filter(Boolean).pop() || "unknown";
8
+ }
9
+
10
+ function osName(osPlatform: NodeJS.Platform): string {
11
+ switch (osPlatform) {
12
+ case "darwin":
13
+ return "macOS";
14
+ case "linux":
15
+ return "Linux";
16
+ case "win32":
17
+ return "Windows";
18
+ default:
19
+ return type();
20
+ }
21
+ }
22
+
23
+ export function getRuntimeOsInfo(): Record<string, string> {
24
+ const osPlatform = platform();
25
+ return {
26
+ osName: osName(osPlatform),
27
+ osType: type(),
28
+ osRelease: release(),
29
+ osPlatform,
30
+ osArch: arch(),
31
+ shell: shellName(),
32
+ };
33
+ }