niahere 0.2.75 → 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.75",
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": {
@@ -259,8 +259,8 @@ class SlackChannel implements Channel {
259
259
  });
260
260
 
261
261
  // Disk-backed file cache: download once, read from disk on subsequent requests
262
- const attachDir = join(getNiaHome(), "tmp", "attachments");
263
- mkdirSync(attachDir, { recursive: true });
262
+ const attachRoot = join(getNiaHome(), "tmp", "attachments");
263
+ mkdirSync(attachRoot, { recursive: true });
264
264
 
265
265
  interface CachedFile {
266
266
  path: string;
@@ -292,26 +292,39 @@ class SlackChannel implements Channel {
292
292
  return Buffer.from(await resp.arrayBuffer());
293
293
  }
294
294
 
295
- async function extractSlackAttachments(files: any[]): Promise<Attachment[]> {
295
+ function cacheDirForScope(scope: string): string {
296
+ const safeScope = scope.replace(/[^a-zA-Z0-9._-]/g, "_");
297
+ const dir = join(attachRoot, safeScope);
298
+ mkdirSync(dir, { recursive: true });
299
+ return dir;
300
+ }
301
+
302
+ function cacheKey(scope: string, url: string): string {
303
+ return `${scope}:${url}`;
304
+ }
305
+
306
+ async function extractSlackAttachments(files: any[], scope: string): Promise<Attachment[]> {
296
307
  const attachments: Attachment[] = [];
297
- for (const file of files.slice(0, 5)) {
308
+ const scopedAttachDir = cacheDirForScope(scope);
309
+ for (const file of files) {
298
310
  const mime = file.mimetype || "application/octet-stream";
299
311
  const attType = classifyMime(mime);
300
312
  if (!attType) continue;
301
313
  if (!file.url_private_download) continue;
302
314
 
303
315
  // Check in-memory index first
304
- const cached = fileIndex.get(file.url_private_download);
316
+ const indexedKey = cacheKey(scope, file.url_private_download);
317
+ const cached = fileIndex.get(indexedKey);
305
318
  if (cached && existsSync(cached.path)) {
306
319
  attachments.push(loadCached(cached));
307
320
  continue;
308
321
  }
309
322
 
310
- // Check disk (survives daemon restarts) — keyed by URL hash only (global dedup)
323
+ // Check disk (survives daemon restarts) — scoped by Slack room/thread.
311
324
  const hash = urlHash(file.url_private_download);
312
325
  const ext = file.name?.split(".").pop() || "bin";
313
- const diskPath = join(attachDir, `${hash}.${ext}`);
314
- const metaPath = join(attachDir, `${hash}.meta.json`);
326
+ const diskPath = join(scopedAttachDir, `${hash}.${ext}`);
327
+ const metaPath = join(scopedAttachDir, `${hash}.meta.json`);
315
328
  if (existsSync(diskPath) && existsSync(metaPath)) {
316
329
  try {
317
330
  const meta = JSON.parse(readFileSync(metaPath, "utf8"));
@@ -321,7 +334,7 @@ class SlackChannel implements Channel {
321
334
  mimeType: meta.mimeType || mime,
322
335
  filename: meta.filename || file.name,
323
336
  };
324
- fileIndex.set(file.url_private_download, entry);
337
+ fileIndex.set(indexedKey, entry);
325
338
  attachments.push(loadCached(entry));
326
339
  continue;
327
340
  } catch {
@@ -348,7 +361,7 @@ class SlackChannel implements Channel {
348
361
  writeFileSync(diskPath, finalData);
349
362
  writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
350
363
  const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
351
- fileIndex.set(file.url_private_download, entry);
364
+ fileIndex.set(indexedKey, entry);
352
365
 
353
366
  attachments.push({ type: attType, data: finalData, mimeType: finalMime, filename: file.name, sourcePath: diskPath });
354
367
  } catch (err) {
@@ -484,7 +497,7 @@ class SlackChannel implements Channel {
484
497
  // Download any file attachments
485
498
  let attachments: Attachment[] | undefined;
486
499
  if (hasFiles) {
487
- attachments = await extractSlackAttachments(msg.files!);
500
+ attachments = await extractSlackAttachments(msg.files!, roomPrefix(key));
488
501
  }
489
502
 
490
503
  if (!text && (!attachments || attachments.length === 0)) return;
@@ -516,25 +529,20 @@ class SlackChannel implements Channel {
516
529
  text = `[Thread context]\n${threadMessages.join("\n")}\n\n[Current message]\n${text}`;
517
530
  }
518
531
 
519
- // Download files from recent thread messages (last 5 messages, max 5 files total)
532
+ // Download files from fetched thread messages.
520
533
  if (!attachments) attachments = [];
521
- const threadFileBudget = 5 - attachments.length;
522
- if (threadFileBudget > 0) {
523
- const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0).slice(-5);
524
- let threadFilesAdded = 0;
525
- for (const m of messagesWithFiles) {
526
- if (threadFilesAdded >= threadFileBudget) break;
527
- const extracted = await extractSlackAttachments(m.files || []);
528
- for (const att of extracted) {
529
- if (threadFilesAdded >= threadFileBudget) break;
530
- attachments.push(att);
531
- threadFilesAdded++;
532
- }
533
- }
534
- if (threadFilesAdded > 0) {
535
- log.info({ threadFiles: threadFilesAdded, channel: msg.channel }, "slack: downloaded thread attachments");
534
+ const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0);
535
+ let threadFilesAdded = 0;
536
+ for (const m of messagesWithFiles) {
537
+ const extracted = await extractSlackAttachments(m.files || [], roomPrefix(key));
538
+ for (const att of extracted) {
539
+ attachments.push(att);
540
+ threadFilesAdded++;
536
541
  }
537
542
  }
543
+ if (threadFilesAdded > 0) {
544
+ log.info({ threadFiles: threadFilesAdded, channel: msg.channel }, "slack: downloaded thread attachments");
545
+ }
538
546
  } catch (err) {
539
547
  log.warn({ err, channel: msg.channel, thread_ts: msg.thread_ts }, "failed to fetch thread context");
540
548
  }
@@ -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
@@ -52,7 +53,7 @@ export function buildContentBlocks(text: string, attachments?: Attachment[]): Me
52
53
  .map((att, idx) => {
53
54
  if (!att.sourcePath) return "";
54
55
  const label = att.filename || `${att.type}-${idx + 1}`;
55
- return `- ${idx + 1}. ${label} -> ${att.sourcePath}`;
56
+ return `- ${idx + 1}. ${label} (${att.type}, ${att.mimeType}) -> ${att.sourcePath}`;
56
57
  })
57
58
  .filter(Boolean);
58
59
 
@@ -61,12 +62,14 @@ export function buildContentBlocks(text: string, attachments?: Attachment[]): Me
61
62
  type: "text",
62
63
  text:
63
64
  "[Attachment local paths]\n" +
64
- "If you need to resend/forward an attachment, call send_message with media_path set to one of these absolute paths.\n" +
65
+ "Use these absolute paths to inspect attachments. To resend/forward one, call send_message with media_path set to its path.\n" +
65
66
  pathHints.join("\n"),
66
67
  });
67
68
  }
68
69
 
69
70
  for (const att of attachments) {
71
+ if (att.sourcePath) continue;
72
+
70
73
  if (att.type === "image") {
71
74
  blocks.push({
72
75
  type: "image",
@@ -270,9 +273,20 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
270
273
  queryHandle.close();
271
274
  queryHandle = null;
272
275
  }
276
+ unregisterActiveHandle(room);
273
277
  alive = false;
274
278
  }
275
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
+
276
290
  function startQuery() {
277
291
  stream = new MessageStream();
278
292
  alive = true;
@@ -307,6 +321,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
307
321
  prompt: stream as any,
308
322
  options: options as any,
309
323
  });
324
+ registerActiveHandle(room, abortActiveQuery);
310
325
 
311
326
  // Background consumer — runs for the lifetime of the query
312
327
  (async () => {
@@ -521,6 +536,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
521
536
  }
522
537
  } finally {
523
538
  clearLongRunningTimer();
539
+ unregisterActiveHandle(room);
524
540
  alive = false;
525
541
  stream = null;
526
542
  queryHandle = null;
@@ -594,8 +610,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
594
610
  log.error({ err, room }, "finalization enqueue failed during close");
595
611
  }
596
612
  }
597
- teardown();
598
- await ActiveEngine.unregister(room).catch(() => {});
613
+ await abortActiveQuery("chat engine closed");
599
614
  },
600
615
  };
601
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
 
@@ -1,4 +1,4 @@
1
- export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB
1
+ export const MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB
2
2
 
3
3
  export const IMAGE_MIMES = new Set([
4
4
  "image/jpeg",
@@ -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
  }
@@ -24,7 +24,7 @@
24
24
  ### Reply routing
25
25
  - Always reply in the same thread you received the message in. Don't DM someone unless the conversation is already in DMs.
26
26
  - `send_message` defaults to your current context (thread if in one, DM if in DM). For escalations, mention the owner in-thread rather than DMing — keeps context where the conversation is.
27
- - If the user wants a file/image sent, use `send_message` with `media_path`. When a Slack file was attached to the message, use the `[Attachment local paths]` block from context.
27
+ - If the user wants you to inspect or send a file/image, use the absolute paths in the `[Attachment local paths]` block. Use `send_message` with `media_path` when forwarding one.
28
28
 
29
29
  ### Who's talking
30
30
  - Multiple users may message you. Slack messages are prefixed with [user:ID] so you know who's talking (in channels and DMs).
@@ -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
@@ -43,7 +50,7 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
43
50
  - `auto` (default) — replies in the current Slack thread if you're in one, otherwise DMs the owner. This means watch sessions and thread chats reply in-thread by default.
44
51
  - `dm` — always DMs the owner, regardless of current context. Use sparingly — prefer @mentioning the owner in-thread to keep context visible.
45
52
  - `thread` — explicitly reply in the current thread (same as auto when in a thread, falls back to DM otherwise).
46
- For inbound channel files, check the message context for an `[Attachment local paths]` block and reuse those absolute paths when forwarding.
53
+ For inbound channel files, check the message context for an `[Attachment local paths]` block and use those absolute paths for inspection or forwarding.
47
54
  - **list_messages** — read recent chat history
48
55
  - **list_sessions** — browse past conversation sessions with previews and message counts. Returns session IDs.
49
56
  - **search_messages** — keyword search across all past messages. Find when something was discussed.
@@ -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
+ }