slack-term 1.35.0 → 1.37.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/leveldb.ts ADDED
@@ -0,0 +1,154 @@
1
+ // Minimal read-only LevelDB SSTable (.ldb) reader + Snappy decompressor.
2
+ //
3
+ // Chrome/Electron store LocalStorage in LevelDB, and its .ldb (sorted table)
4
+ // files keep data blocks Snappy-compressed, so a raw regex scan can't recover a
5
+ // value that a Snappy copy-op split apart. This module decompresses the data
6
+ // blocks and returns their concatenated bytes for scanning — it recovers record
7
+ // VALUES only (enough to find "token":"xoxc-…"); it is not a general client and
8
+ // does no key lookup, bloom/filter handling, or CRC verification.
9
+ //
10
+ // SSTable layout (github.com/google/leveldb/blob/main/doc/table_format.md):
11
+ // file = block* + metaindex_block + index_block + footer
12
+ // block = content-bytes + type(1) + crc32c(4) (type 0 = raw, 1 = snappy)
13
+ // footer (last 48 bytes) = metaindex_handle + index_handle + pad + magic(8)
14
+ // BlockHandle = varint(offset) + varint(size) (size excludes the 5-byte trailer)
15
+
16
+ const FOOTER_LEN = 48;
17
+
18
+ /** Read a LEB128 varint using float arithmetic so 64-bit offsets/sizes stay exact past 2^31. */
19
+ function readVarint(buf: Buffer, pos: number): { value: number; pos: number } {
20
+ let value = 0;
21
+ let shift = 0;
22
+ let p = pos;
23
+ while (p < buf.length) {
24
+ const b = buf[p++]!;
25
+ value += (b & 0x7f) * 2 ** shift;
26
+ if ((b & 0x80) === 0) break;
27
+ shift += 7;
28
+ }
29
+ return { value, pos: p };
30
+ }
31
+
32
+ /** Decompress a Snappy block (raw format: varint uncompressed-length preamble, then tags). */
33
+ export function snappyUncompress(input: Buffer): Buffer {
34
+ // Preamble: total uncompressed length.
35
+ let pos = 0;
36
+ let outLen = 0;
37
+ let shift = 0;
38
+ while (pos < input.length) {
39
+ const b = input[pos++]!;
40
+ outLen += (b & 0x7f) * 2 ** shift;
41
+ if ((b & 0x80) === 0) break;
42
+ shift += 7;
43
+ }
44
+
45
+ const out = Buffer.allocUnsafe(outLen);
46
+ let op = 0;
47
+ while (pos < input.length && op < outLen) {
48
+ const tag = input[pos++]!;
49
+ const type = tag & 0x3;
50
+ if (type === 0) {
51
+ // Literal: length-1 in the upper 6 bits, or in the following 1-4 bytes when >= 60.
52
+ let len = tag >> 2;
53
+ if (len >= 60) {
54
+ const extra = len - 59;
55
+ len = 0;
56
+ for (let i = 0; i < extra; i++) len += input[pos++]! * 2 ** (8 * i);
57
+ }
58
+ len += 1;
59
+ input.copy(out, op, pos, pos + len);
60
+ pos += len;
61
+ op += len;
62
+ } else {
63
+ // Copy: length + back-reference offset into the already-produced output.
64
+ let length: number;
65
+ let offset: number;
66
+ if (type === 1) {
67
+ length = 4 + ((tag >> 2) & 0x7);
68
+ offset = ((tag >> 5) & 0x7) * 256 + input[pos++]!;
69
+ } else if (type === 2) {
70
+ length = 1 + (tag >> 2);
71
+ offset = input[pos]! + input[pos + 1]! * 256;
72
+ pos += 2;
73
+ } else {
74
+ length = 1 + (tag >> 2);
75
+ offset = input[pos]! + input[pos + 1]! * 256 + input[pos + 2]! * 65536 + input[pos + 3]! * 16777216;
76
+ pos += 4;
77
+ }
78
+ let src = op - offset;
79
+ for (let i = 0; i < length && op < outLen; i++) out[op++] = out[src++]!;
80
+ }
81
+ }
82
+ return op === outLen ? out : out.subarray(0, op);
83
+ }
84
+
85
+ type BlockHandle = { offset: number; size: number };
86
+
87
+ function decodeBlockHandle(buf: Buffer, pos: number): { handle: BlockHandle; pos: number } {
88
+ const off = readVarint(buf, pos);
89
+ const sz = readVarint(buf, off.pos);
90
+ return { handle: { offset: off.value, size: sz.value }, pos: sz.pos };
91
+ }
92
+
93
+ /** Read one block's content, decompressing when the trailer marks it Snappy (type 1). */
94
+ function readBlockContent(file: Buffer, handle: BlockHandle): Buffer {
95
+ const { offset, size } = handle;
96
+ const raw = file.subarray(offset, offset + size);
97
+ const type = file[offset + size];
98
+ return type === 1 ? snappyUncompress(raw) : Buffer.from(raw);
99
+ }
100
+
101
+ /** Iterate a decompressed block's entries, yielding each value's bytes (keys reconstructed but unused here). */
102
+ function blockValues(block: Buffer): Buffer[] {
103
+ if (block.length < 4) return [];
104
+ const numRestarts = block.readUInt32LE(block.length - 4);
105
+ const entriesEnd = block.length - (numRestarts + 1) * 4;
106
+ const values: Buffer[] = [];
107
+ let pos = 0;
108
+ let prevKey = Buffer.alloc(0);
109
+ while (pos < entriesEnd) {
110
+ const s = readVarint(block, pos);
111
+ const ns = readVarint(block, s.pos);
112
+ const vl = readVarint(block, ns.pos);
113
+ pos = vl.pos;
114
+ const shared = s.value;
115
+ const nonShared = ns.value;
116
+ const valueLen = vl.value;
117
+ const keyDelta = block.subarray(pos, pos + nonShared);
118
+ pos += nonShared;
119
+ const value = block.subarray(pos, pos + valueLen);
120
+ pos += valueLen;
121
+ prevKey = Buffer.concat([prevKey.subarray(0, shared), keyDelta]);
122
+ values.push(value);
123
+ }
124
+ return values;
125
+ }
126
+
127
+ /**
128
+ * Decompress every data block referenced by an SSTable's index and return their
129
+ * concatenated bytes. Record values survive intact (Slack stores a workspace's
130
+ * config as one JSON string value), so the result can be regex-scanned.
131
+ * Returns an empty buffer if the file isn't a parseable SSTable.
132
+ */
133
+ export function ldbDecompressedBytes(file: Buffer): Buffer {
134
+ if (file.length < FOOTER_LEN) return Buffer.alloc(0);
135
+ try {
136
+ const footerStart = file.length - FOOTER_LEN;
137
+ const meta = decodeBlockHandle(file, footerStart); // metaindex handle (skipped)
138
+ const index = decodeBlockHandle(file, meta.pos); // index handle
139
+ const indexBlock = readBlockContent(file, index.handle);
140
+ const chunks: Buffer[] = [];
141
+ for (const handleBytes of blockValues(indexBlock)) {
142
+ // Each index entry's value is a BlockHandle pointing at a data block.
143
+ try {
144
+ const { handle } = decodeBlockHandle(handleBytes, 0);
145
+ chunks.push(readBlockContent(file, handle));
146
+ } catch {
147
+ // skip a malformed handle
148
+ }
149
+ }
150
+ return Buffer.concat(chunks);
151
+ } catch {
152
+ return Buffer.alloc(0);
153
+ }
154
+ }
package/ts/profiles.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  // Note: lockfiles and profiles.json use .slack-cli/ (older name); new per-dir token storage
21
21
  // uses .slack-term/ (current project name). Both coexist intentionally.
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { join, dirname } from "node:path";
26
26
 
@@ -30,11 +30,19 @@ export type Profile = {
30
30
  teamId: string;
31
31
  url: string;
32
32
  user: string;
33
- cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
33
+ cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
34
+ identity?: string; // owning user identity (e.g. account email) — groups workspaces and shares one cookie
35
+ };
36
+
37
+ /** Per-identity auth shared across all that identity's workspaces. */
38
+ export type UserAuth = {
39
+ cookie?: string; // xoxd session cookie (workspace-agnostic — one per browser/user)
40
+ source?: string; // where it came from, e.g. "chrome:Profile 3"
34
41
  };
35
42
 
36
43
  type ProfileStore = {
37
44
  profiles: Record<string, Profile>;
45
+ users?: Record<string, UserAuth>; // identity -> shared auth; cookie stored here once
38
46
  };
39
47
 
40
48
  function home(): string {
@@ -62,7 +70,9 @@ function load(): ProfileStore {
62
70
  function save(store: ProfileStore): void {
63
71
  const path = profilesPath();
64
72
  mkdirSync(dirname(path), { recursive: true });
65
- writeFileSync(path, JSON.stringify(store, null, 2) + "\n");
73
+ if (process.platform !== "win32" && existsSync(path)) chmodSync(path, 0o600);
74
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
75
+ if (process.platform !== "win32") chmodSync(path, 0o600);
66
76
  }
67
77
 
68
78
  function readLockfile(path: string): string | undefined {
@@ -150,7 +160,9 @@ export function saveToEnvFile(filePath: string, updates: Record<string, string>)
150
160
  lines.push(newLine);
151
161
  }
152
162
  }
153
- writeFileSync(filePath, lines.join("\n").trimEnd() + "\n");
163
+ if (process.platform !== "win32" && existsSync(filePath)) chmodSync(filePath, 0o600);
164
+ writeFileSync(filePath, lines.join("\n").trimEnd() + "\n", { mode: 0o600 });
165
+ if (process.platform !== "win32") chmodSync(filePath, 0o600);
154
166
  }
155
167
 
156
168
  export function listProfiles(): { name: string; profile: Profile; current: boolean }[] {
@@ -158,26 +170,66 @@ export function listProfiles(): { name: string; profile: Profile; current: boole
158
170
  const local = readLockfile(localLockfilePath());
159
171
  const global_ = readLockfile(globalLockfilePath());
160
172
  const current = local ?? global_;
161
- return Object.entries(store.profiles).map(([name, profile]) => ({
162
- name,
163
- profile,
164
- current: name === current,
165
- }));
173
+ return Object.entries(store.profiles).map(([name, profile]) => {
174
+ // Surface the effective cookie (own, else the identity's shared one) so
175
+ // callers reading profile.cookie keep working after cookie hoisting.
176
+ const cookie = cookieFor(store, name);
177
+ return {
178
+ name,
179
+ profile: { ...profile, ...(cookie ? { cookie } : {}) },
180
+ current: name === current,
181
+ };
182
+ });
166
183
  }
167
184
 
168
185
  export function addProfile(name: string, profile: Profile): void {
169
186
  const store = load();
170
- store.profiles[name] = profile;
187
+ // When the profile belongs to an identity, store its cookie once at the
188
+ // identity level (workspace-agnostic) rather than duplicating it per workspace.
189
+ if (profile.identity && profile.cookie) {
190
+ store.users ??= {};
191
+ const prev = store.users[profile.identity] ?? {};
192
+ store.users[profile.identity] = { ...prev, cookie: profile.cookie };
193
+ const { cookie: _drop, ...rest } = profile;
194
+ store.profiles[name] = rest;
195
+ } else {
196
+ store.profiles[name] = profile;
197
+ }
171
198
  save(store);
172
199
  }
173
200
 
174
201
  export function setCookie(name: string, cookie: string): void {
175
202
  const store = load();
176
- if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
177
- store.profiles[name]!.cookie = cookie;
203
+ const profile = store.profiles[name];
204
+ if (!profile) throw new Error(`Profile not found: ${name}`);
205
+ // Route through the identity's shared cookie when the workspace has one.
206
+ if (profile.identity) {
207
+ store.users ??= {};
208
+ store.users[profile.identity] = { ...(store.users[profile.identity] ?? {}), cookie };
209
+ } else {
210
+ profile.cookie = cookie;
211
+ }
178
212
  save(store);
179
213
  }
180
214
 
215
+ /** Store (or refresh) the shared xoxd cookie for an identity — every workspace under it picks it up. */
216
+ export function setUserCookie(identity: string, cookie: string, source?: string): void {
217
+ const store = load();
218
+ store.users ??= {};
219
+ store.users[identity] = { ...(store.users[identity] ?? {}), cookie, ...(source ? { source } : {}) };
220
+ save(store);
221
+ }
222
+
223
+ /** Resolve the effective xoxd cookie for a workspace: its own, else its identity's shared one. */
224
+ function cookieFor(store: ProfileStore, name: string | undefined): string | undefined {
225
+ if (!name) return undefined;
226
+ const profile = store.profiles[name];
227
+ if (!profile) return undefined;
228
+ if (profile.cookie) return profile.cookie;
229
+ if (profile.identity) return store.users?.[profile.identity]?.cookie;
230
+ return undefined;
231
+ }
232
+
181
233
  export function removeProfile(name: string): void {
182
234
  const store = load();
183
235
  if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
@@ -270,9 +322,11 @@ export function resolveToken(workspaceFlag?: string): string {
270
322
  throw new Error(
271
323
  "No Slack token found.\n" +
272
324
  " Run one of:\n" +
273
- " slack auth login — paste an existing xoxp-/xoxb- token\n" +
274
- " slack auth chrome — import from Chrome browser (macOS)\n" +
275
- " slack auth app — guided Slack app creation\n" +
325
+ " slack auth login — interactive wizard (desktop / browser / token / new app)\n" +
326
+ " slack auth login chrome — import workspaces from Chrome (all platforms)\n" +
327
+ " slack auth cookie — attach the xoxd cookie from Chrome (macOS/Windows)\n" +
328
+ " slack auth firefox — attach the xoxd cookie from Firefox (all platforms)\n" +
329
+ " slack auth token --token xoxp-... — paste an existing token\n" +
276
330
  " Or set SLACK_TOKEN=xoxp-... in .slack-term/.env.local",
277
331
  );
278
332
  }
@@ -294,7 +348,7 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
294
348
  const profiles = store.profiles;
295
349
 
296
350
  // --workspace flag: skip env-file walk, go straight to profiles.json
297
- if (workspaceFlag) return profiles[workspaceFlag]?.cookie;
351
+ if (workspaceFlag) return cookieFor(store, workspaceFlag);
298
352
 
299
353
  // process.env: SLACK_COOKIE (official extension), legacy SLACK_MCP_XOXD_COOKIE
300
354
  if (process.env.SLACK_COOKIE) return process.env.SLACK_COOKIE;
@@ -312,13 +366,13 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
312
366
 
313
367
  // profiles.json via SLACK_WORKSPACE env var or lockfiles
314
368
  const selected = process.env.SLACK_WORKSPACE;
315
- if (selected) return profiles[selected]?.cookie;
369
+ if (selected) return cookieFor(store, selected);
316
370
 
317
371
  const localName = readLockfile(localLockfilePath());
318
- if (localName) return profiles[localName]?.cookie;
372
+ if (localName) return cookieFor(store, localName);
319
373
 
320
374
  const globalName = readLockfile(globalLockfilePath());
321
- if (globalName) return profiles[globalName]?.cookie;
375
+ if (globalName) return cookieFor(store, globalName);
322
376
 
323
377
  return undefined;
324
378
  }
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
+ }