slack-term 1.41.1 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/sentlog.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // Opt out with SLACK_TERM_ATTRIBUTION=off (no log, no metadata).
20
20
 
21
21
  import { spawnSync } from "node:child_process";
22
- import { chmodSync, mkdirSync } from "node:fs";
22
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
23
23
  import { homedir, hostname } from "node:os";
24
24
  import { basename, dirname, join } from "node:path";
25
25
 
@@ -63,7 +63,13 @@ export interface SentRow {
63
63
  collected_at?: number | null;
64
64
  answer?: string | null;
65
65
  answer_exit?: number | null;
66
+ /** Set whenever `--wait`/`--waitFor` handed an answer to its caller — the
67
+ * decision (exit 0) or a free-text reply (exit 5) — with WHICH answer, and
68
+ * the `--after` cursor that delivery ended at. `ask --pending` skips an
69
+ * answer already delivered, and resumes from that cursor. */
66
70
  delivered_at?: number | null;
71
+ delivered_answer?: string | null;
72
+ delivered_cursor?: string | null;
67
73
  }
68
74
 
69
75
  export function attributionEnabled(): boolean {
@@ -231,7 +237,9 @@ CREATE TABLE IF NOT EXISTS sent (
231
237
  collected_at INTEGER,
232
238
  answer TEXT,
233
239
  answer_exit INTEGER,
234
- delivered_at INTEGER
240
+ delivered_at INTEGER,
241
+ delivered_answer TEXT,
242
+ delivered_cursor TEXT
235
243
  );
236
244
  CREATE INDEX IF NOT EXISTS sent_msg ON sent(channel, ts);
237
245
  CREATE INDEX IF NOT EXISTS sent_at ON sent(sent_at);
@@ -252,6 +260,11 @@ export function openSentLog(): Db | undefined {
252
260
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
253
261
  try {
254
262
  db.exec(SCHEMA);
263
+ // Columns added after the table first shipped. ALTER fails harmlessly when
264
+ // the column is already there.
265
+ for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT"]) {
266
+ try { db.exec(`ALTER TABLE sent ADD COLUMN ${col}`); } catch { /* exists */ }
267
+ }
255
268
  return db;
256
269
  } catch {
257
270
  try { db.close(); } catch { /* ignore */ }
@@ -354,3 +367,94 @@ export function parseSince(s: string, now = Date.now()): number | undefined {
354
367
  const t = Date.parse(s);
355
368
  return Number.isNaN(t) ? undefined : t;
356
369
  }
370
+
371
+ // --- ask collection ----------------------------------------------------------
372
+
373
+ /** Run one bookkeeping UPDATE against an EXISTING log. Never creates the file
374
+ * (no log means no row to update) and never throws. */
375
+ function updateLog(sql: string, ...params: unknown[]): void {
376
+ if (!attributionEnabled() || !existsSync(sentDbPath())) return;
377
+ const db = openSentLog();
378
+ if (!db) return;
379
+ try {
380
+ db.prepare(sql).run(...params);
381
+ } catch {
382
+ // bookkeeping only
383
+ } finally {
384
+ try { db.close(); } catch { /* ignore */ }
385
+ }
386
+ }
387
+
388
+ /** The caller of `--wait`/`--waitFor` got the DECISION (exit 0): the ask is
389
+ * collected and drops off `ask --pending`. A no-op when the ask is not in this
390
+ * machine's log (asked elsewhere). */
391
+ export function markAskCollected(channel: string, ts: string, answer: string, cursor: string): void {
392
+ const now = Date.now();
393
+ updateLog(
394
+ `UPDATE sent SET collected_at = COALESCE(collected_at, ?), answer = ?, answer_exit = 0,
395
+ delivered_at = ?, delivered_answer = ?, delivered_cursor = ?
396
+ WHERE kind = 'ask' AND channel = ? AND ts = ?`,
397
+ now, answer, now, answer, cursor, channel, ts,
398
+ );
399
+ }
400
+
401
+ /** The caller got a free-text reply (exit 5) — delivered, but NOT a decision,
402
+ * so the ask stays uncollected: a pill pressed later is still the answer. */
403
+ export function markAskDelivered(channel: string, ts: string, answer: string, cursor: string): void {
404
+ updateLog(
405
+ `UPDATE sent SET delivered_at = ?, delivered_answer = ?, delivered_cursor = ?
406
+ WHERE kind = 'ask' AND channel = ? AND ts = ? AND collected_at IS NULL`,
407
+ Date.now(), answer, cursor, channel, ts,
408
+ );
409
+ }
410
+
411
+ /** Logged asks nobody has collected yet, oldest first. `undefined` when the
412
+ * log cannot be opened at all (no SQLite in this runtime). */
413
+ export function uncollectedAsks(sinceMs: number): SentRow[] | undefined {
414
+ if (!existsSync(sentDbPath())) return [];
415
+ const db = openSentLog();
416
+ if (!db) return undefined;
417
+ try {
418
+ return db.prepare(
419
+ `SELECT * FROM sent WHERE kind = 'ask' AND collected_at IS NULL AND sent_at >= ? ORDER BY sent_at ASC, id ASC`,
420
+ ).all(sinceMs) as SentRow[];
421
+ } finally {
422
+ try { db.close(); } catch { /* ignore */ }
423
+ }
424
+ }
425
+
426
+ /** Is the agent that sent this row still the process at that pid? A pid alone
427
+ * is not enough — pids are reused, and telling someone "the asker is still
428
+ * running" about whatever now holds the number would send them to the wrong
429
+ * agent. So the process must also (a) have started before the message was
430
+ * sent, and (b) carry the process name of the recorded CLI, when that CLI is
431
+ * one we know by name. */
432
+ export function senderAlive(row: Pick<SentRow, "agent_pid" | "pid" | "cli" | "sent_at">): boolean {
433
+ const pid = row.agent_pid ?? row.pid;
434
+ if (!pidAlive(pid)) return false;
435
+ try {
436
+ const r = spawnSync("ps", ["-o", "lstart=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
437
+ const line = (r.stdout ?? "").trim();
438
+ // lstart is a fixed 24-char date ("Sun Sep 27 10:00:00 2026"), comm follows.
439
+ const started = Date.parse(line.slice(0, 24));
440
+ if (!Number.isNaN(started) && started > row.sent_at + 1000) return false;
441
+ const comm = basename(line.slice(24).trim());
442
+ const known = new Set(Object.values(AGENT_PROCESS_NAMES));
443
+ if (row.cli && known.has(row.cli) && comm && AGENT_PROCESS_NAMES[comm] !== row.cli) return false;
444
+ } catch {
445
+ // ps unavailable: fall back to the bare pid check
446
+ }
447
+ return true;
448
+ }
449
+
450
+ /** Is this pid still running? `kill(pid, 0)` checks without signalling;
451
+ * EPERM means it exists but belongs to someone else — still alive. */
452
+ export function pidAlive(pid: number | null | undefined): boolean {
453
+ if (!pid || pid <= 0) return false;
454
+ try {
455
+ process.kill(pid, 0);
456
+ return true;
457
+ } catch (e: unknown) {
458
+ return (e as { code?: string }).code === "EPERM";
459
+ }
460
+ }
package/ts/slack.ts CHANGED
@@ -852,8 +852,9 @@ function userNotFoundError(ref: string, found: UserSearch): Error {
852
852
 
853
853
  /**
854
854
  * Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
855
- * the lookup runs on a *user* token (which has users:read), then the caller
856
- * opens the DM with the bot token — so the bot never needs users:read.
855
+ * the caller then opens the DM with the bot token. `send` runs the lookup on the
856
+ * *user* token; `ask`/`poll --as-bot` run it on the bot token (the bot scopes
857
+ * include users:read), so a dead user token cannot break a bot question.
857
858
  */
858
859
  export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
859
860
  // `@U0123456789` — an id wearing the `@` that every other user target takes.