slack-term 1.34.0 → 1.36.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 ADDED
@@ -0,0 +1,356 @@
1
+ // Local record of every message this CLI posted or edited, and WHO posted it.
2
+ //
3
+ // Why this exists: an agent asked a question, the human answered it, and no one
4
+ // collected the answer. Finding which agent had asked took a grep over ~1.8 GB
5
+ // of session transcripts, then matching cwd and start time to a live pid
6
+ // (measured 2026-09-27). The sender is known for certain at the moment of
7
+ // sending — this file writes it down then, instead of reconstructing it later.
8
+ //
9
+ // Two places, on purpose:
10
+ // - a local SQLite log (~/.config/slack-cli/sent.sqlite), which `slack sent`
11
+ // and `slack ask --pending` read;
12
+ // - Slack message `metadata` (event_type `slack_term_sent`), invisible to
13
+ // readers but returned by the API, so a machine WITHOUT the log can still
14
+ // attribute a message.
15
+ //
16
+ // Everything here is fail-soft. A message that was posted must never be
17
+ // reported as failed because the bookkeeping about it failed.
18
+ //
19
+ // Opt out with SLACK_TERM_ATTRIBUTION=off (no log, no metadata).
20
+
21
+ import { spawnSync } from "node:child_process";
22
+ import { chmodSync, mkdirSync } from "node:fs";
23
+ import { homedir, hostname } from "node:os";
24
+ import { basename, dirname, join } from "node:path";
25
+
26
+ export type SentKind = "send" | "ask" | "edit" | "poll";
27
+
28
+ /** Who sent it. Every field is best-effort; a missing one is left undefined
29
+ * rather than guessed. */
30
+ export interface Attribution {
31
+ /** The `slack` process itself — short-lived, kept for completeness. */
32
+ pid: number;
33
+ /** The long-lived agent process that ran `slack` — what "is the sender still
34
+ * alive?" has to check. */
35
+ agentPid?: number;
36
+ host: string;
37
+ cwd: string;
38
+ gitBranch?: string;
39
+ /** claude-code, codex, … — see detectCli. */
40
+ cli?: string;
41
+ sessionId?: string;
42
+ }
43
+
44
+ export interface SentRow {
45
+ id: number;
46
+ kind: SentKind;
47
+ team?: string | null;
48
+ channel: string;
49
+ target?: string | null;
50
+ ts: string;
51
+ thread_ts?: string | null;
52
+ permalink?: string | null;
53
+ text: string;
54
+ sent_at: number;
55
+ pid: number;
56
+ agent_pid?: number | null;
57
+ host?: string | null;
58
+ cwd: string;
59
+ git_branch?: string | null;
60
+ cli?: string | null;
61
+ session_id?: string | null;
62
+ as_bot: number;
63
+ collected_at?: number | null;
64
+ answer?: string | null;
65
+ answer_exit?: number | null;
66
+ delivered_at?: number | null;
67
+ }
68
+
69
+ export function attributionEnabled(): boolean {
70
+ return !/^(0|off|false|no)$/i.test(process.env.SLACK_TERM_ATTRIBUTION ?? "");
71
+ }
72
+
73
+ // --- attribution -------------------------------------------------------------
74
+
75
+ /** Agent CLIs recognised by process name when no env var names them. */
76
+ const AGENT_PROCESS_NAMES: Record<string, string> = {
77
+ claude: "claude-code",
78
+ codex: "codex",
79
+ gemini: "gemini",
80
+ opencode: "opencode",
81
+ "cursor-agent": "cursor-agent",
82
+ aider: "aider",
83
+ amp: "amp",
84
+ };
85
+
86
+ type Proc = { pid: number; ppid: number; comm: string };
87
+
88
+ function procInfo(pid: number): Proc | undefined {
89
+ try {
90
+ const r = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
91
+ const m = (r.stdout ?? "").trim().match(/^(\d+)\s+(.+)$/);
92
+ if (!m) return undefined;
93
+ return { pid, ppid: Number(m[1]), comm: basename(m[2]!.trim()) };
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /** The nearest ancestor whose process name is a known agent CLI. */
100
+ function findAgentAncestor(): Proc | undefined {
101
+ let pid = process.ppid;
102
+ for (let i = 0; i < 12 && pid > 1; i++) {
103
+ const p = procInfo(pid);
104
+ if (!p) return undefined;
105
+ if (AGENT_PROCESS_NAMES[p.comm]) return p;
106
+ pid = p.ppid;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ function positiveInt(s: string | undefined): number | undefined {
112
+ const n = Number(s);
113
+ return Number.isInteger(n) && n > 0 ? n : undefined;
114
+ }
115
+
116
+ /** Read the sender off the environment and process tree.
117
+ *
118
+ * Env vars used, and only those verified against a real session (2026-09-27):
119
+ * Claude Code sets CLAUDE_CODE_SESSION_ID, CLAUDE_PID and AI_AGENT
120
+ * (`claude-code_<version>_agent`). Other CLIs are recognised by the process
121
+ * name of an ancestor; their session ids are not read because their variable
122
+ * names were not verified. A wrapper that knows better sets the overrides:
123
+ * SLACK_TERM_AGENT_CLI, SLACK_TERM_AGENT_SESSION, SLACK_TERM_AGENT_PID. */
124
+ export function captureAttribution(env: NodeJS.ProcessEnv = process.env): Attribution {
125
+ const a: Attribution = { pid: process.pid, host: safeHostname(), cwd: process.cwd() };
126
+
127
+ let cli = env.SLACK_TERM_AGENT_CLI || undefined;
128
+ if (!cli && env.AI_AGENT) cli = env.AI_AGENT.split("_")[0] || undefined;
129
+ if (!cli && env.CLAUDECODE === "1") cli = "claude-code";
130
+
131
+ let agentPid = positiveInt(env.SLACK_TERM_AGENT_PID) ?? positiveInt(env.CLAUDE_PID);
132
+ if (!agentPid || !cli) {
133
+ const anc = findAgentAncestor();
134
+ if (anc) {
135
+ agentPid ??= anc.pid;
136
+ cli ??= AGENT_PROCESS_NAMES[anc.comm];
137
+ }
138
+ }
139
+ if (cli) a.cli = cli;
140
+ if (agentPid) a.agentPid = agentPid;
141
+
142
+ const session = env.SLACK_TERM_AGENT_SESSION || env.CLAUDE_CODE_SESSION_ID;
143
+ if (session) a.sessionId = session;
144
+
145
+ const branch = gitBranch(a.cwd);
146
+ if (branch) a.gitBranch = branch;
147
+ return a;
148
+ }
149
+
150
+ function safeHostname(): string {
151
+ try {
152
+ return hostname();
153
+ } catch {
154
+ return "";
155
+ }
156
+ }
157
+
158
+ function gitBranch(cwd: string): string | undefined {
159
+ try {
160
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8", timeout: 2000 });
161
+ const b = r.status === 0 ? (r.stdout ?? "").trim() : "";
162
+ return b || undefined;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ /** Slack message metadata: invisible in the client, returned by the API
169
+ * (`include_all_metadata`). Only defined fields — Slack rejects nulls. */
170
+ export const METADATA_EVENT_TYPE = "slack_term_sent";
171
+
172
+ export function attributionMetadata(kind: SentKind, a: Attribution): { event_type: string; event_payload: Record<string, string | number> } {
173
+ const p: Record<string, string | number> = { kind, pid: a.pid, host: a.host, cwd: a.cwd };
174
+ if (a.agentPid) p.agent_pid = a.agentPid;
175
+ if (a.gitBranch) p.git_branch = a.gitBranch;
176
+ if (a.cli) p.cli = a.cli;
177
+ if (a.sessionId) p.session_id = a.sessionId;
178
+ return { event_type: METADATA_EVENT_TYPE, event_payload: p };
179
+ }
180
+
181
+ // --- the database ------------------------------------------------------------
182
+
183
+ /** The subset of bun:sqlite / node:sqlite both provide. */
184
+ interface Stmt { run(...p: unknown[]): unknown; all(...p: unknown[]): unknown[]; }
185
+ interface Db { exec(sql: string): void; prepare(sql: string): Stmt; close(): void; }
186
+
187
+ export function sentDbPath(): string {
188
+ if (process.env.SLACK_TERM_SENT_DB) return process.env.SLACK_TERM_SENT_DB;
189
+ return join(process.env.HOME || homedir(), ".config", "slack-cli", "sent.sqlite");
190
+ }
191
+
192
+ function openSqlite(path: string): Db | undefined {
193
+ // bun:sqlite under bun (how the tests and `bun run` execute), node:sqlite
194
+ // under node 22.5+ (the published dist build). Neither → no log, said once.
195
+ try {
196
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
197
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
198
+ const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
199
+ return new Database(path, { create: true }) as unknown as Db;
200
+ }
201
+ const mod = (process as unknown as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule?.("node:sqlite") as
202
+ | { DatabaseSync: new (p: string) => Db }
203
+ | undefined;
204
+ if (mod?.DatabaseSync) return new mod.DatabaseSync(path);
205
+ } catch {
206
+ // fall through
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ const SCHEMA = `
212
+ CREATE TABLE IF NOT EXISTS sent (
213
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
214
+ kind TEXT NOT NULL,
215
+ team TEXT,
216
+ channel TEXT NOT NULL,
217
+ target TEXT,
218
+ ts TEXT NOT NULL,
219
+ thread_ts TEXT,
220
+ permalink TEXT,
221
+ text TEXT NOT NULL,
222
+ sent_at INTEGER NOT NULL,
223
+ pid INTEGER NOT NULL,
224
+ agent_pid INTEGER,
225
+ host TEXT,
226
+ cwd TEXT NOT NULL,
227
+ git_branch TEXT,
228
+ cli TEXT,
229
+ session_id TEXT,
230
+ as_bot INTEGER NOT NULL DEFAULT 0,
231
+ collected_at INTEGER,
232
+ answer TEXT,
233
+ answer_exit INTEGER,
234
+ delivered_at INTEGER
235
+ );
236
+ CREATE INDEX IF NOT EXISTS sent_msg ON sent(channel, ts);
237
+ CREATE INDEX IF NOT EXISTS sent_at ON sent(sent_at);
238
+ `;
239
+
240
+ export function openSentLog(): Db | undefined {
241
+ const path = sentDbPath();
242
+ try {
243
+ mkdirSync(dirname(path), { recursive: true });
244
+ } catch {
245
+ // openSqlite reports it
246
+ }
247
+ const db = openSqlite(path);
248
+ if (!db) return undefined;
249
+ // Owner-only: the log holds full outgoing text, DMs and private channels
250
+ // included, plus session ids. Applied on every open so a log created before
251
+ // this (or under a loose umask) is tightened too.
252
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
253
+ try {
254
+ db.exec(SCHEMA);
255
+ return db;
256
+ } catch {
257
+ try { db.close(); } catch { /* ignore */ }
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ export interface RecordSent {
263
+ kind: SentKind;
264
+ team?: string | undefined;
265
+ channel: string;
266
+ target?: string | undefined;
267
+ ts: string;
268
+ threadTs?: string | undefined;
269
+ permalink?: string | undefined;
270
+ text: string;
271
+ asBot?: boolean | undefined;
272
+ attribution: Attribution;
273
+ }
274
+
275
+ /** Append one row. Never throws — returns false and warns on stderr instead. */
276
+ export function recordSent(r: RecordSent): boolean {
277
+ if (!attributionEnabled()) return false;
278
+ const db = openSentLog();
279
+ if (!db) {
280
+ console.error(` (送信ログに記録できませんでした: ${SENT_LOG_UNAVAILABLE} — ${sentDbPath()})`);
281
+ return false;
282
+ }
283
+ try {
284
+ const a = r.attribution;
285
+ db.prepare(
286
+ `INSERT INTO sent (kind, team, channel, target, ts, thread_ts, permalink, text, sent_at, pid, agent_pid, host, cwd, git_branch, cli, session_id, as_bot)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
288
+ ).run(
289
+ r.kind, r.team ?? null, r.channel, r.target ?? null, r.ts, r.threadTs ?? null, r.permalink ?? null, r.text,
290
+ Date.now(), a.pid, a.agentPid ?? null, a.host || null, a.cwd, a.gitBranch ?? null, a.cli ?? null, a.sessionId ?? null,
291
+ r.asBot ? 1 : 0,
292
+ );
293
+ return true;
294
+ } catch (e: unknown) {
295
+ console.error(` (送信ログに記録できませんでした: ${e instanceof Error ? e.message : String(e)})`);
296
+ return false;
297
+ } finally {
298
+ try { db.close(); } catch { /* ignore */ }
299
+ }
300
+ }
301
+
302
+ // --- querying ----------------------------------------------------------------
303
+
304
+ export interface SentQuery {
305
+ text?: string;
306
+ channel?: string;
307
+ sinceMs?: number;
308
+ session?: string;
309
+ cwd?: string;
310
+ kind?: SentKind;
311
+ limit?: number;
312
+ }
313
+
314
+ /** Newest first. `channel` matches the id or the target as typed (`#eng`, `@bob`);
315
+ * `session` and `cwd` are prefix matches; `text` is a case-insensitive substring. */
316
+ /** Why the log cannot be opened in this runtime — the storage is SQLite, which
317
+ * needs bun or node ≥ 22.5 (`node:sqlite`). Surfaced by `slack sent`, which
318
+ * must not answer "no matches" when the truth is "no log". */
319
+ export const SENT_LOG_UNAVAILABLE =
320
+ "送信ログを開けません: SQLite が使えないランタイムです (bun、または node >= 22.5 の node:sqlite が必要)";
321
+
322
+ /** Newest first, or `undefined` when the log cannot be opened at all. */
323
+ export function querySent(q: SentQuery): SentRow[] | undefined {
324
+ const db = openSentLog();
325
+ if (!db) return undefined;
326
+ try {
327
+ const where: string[] = [];
328
+ const params: unknown[] = [];
329
+ if (q.text) { where.push("text LIKE ? ESCAPE '\\'"); params.push(`%${likeEscape(q.text)}%`); }
330
+ if (q.channel) { where.push("(channel = ? OR target = ?)"); params.push(q.channel, q.channel); }
331
+ if (q.sinceMs !== undefined) { where.push("sent_at >= ?"); params.push(q.sinceMs); }
332
+ if (q.session) { where.push("session_id LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(q.session)}%`); }
333
+ if (q.cwd) { where.push("cwd LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(q.cwd)}%`); }
334
+ if (q.kind) { where.push("kind = ?"); params.push(q.kind); }
335
+ const sql = `SELECT * FROM sent${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY sent_at DESC, id DESC LIMIT ?`;
336
+ params.push(q.limit ?? 50);
337
+ return db.prepare(sql).all(...params) as SentRow[];
338
+ } finally {
339
+ try { db.close(); } catch { /* ignore */ }
340
+ }
341
+ }
342
+
343
+ function likeEscape(s: string): string {
344
+ return s.replace(/[\\%_]/g, (c) => `\\${c}`);
345
+ }
346
+
347
+ /** `30m`, `2h`, `7d`, `1w`, or an ISO date/time → epoch ms. Undefined if unreadable. */
348
+ export function parseSince(s: string, now = Date.now()): number | undefined {
349
+ const m = s.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)$/i);
350
+ if (m) {
351
+ const unit = { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[m[2]!.toLowerCase() as "s"]!;
352
+ return now - Number(m[1]) * unit;
353
+ }
354
+ const t = Date.parse(s);
355
+ return Number.isNaN(t) ? undefined : t;
356
+ }