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/README.md +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +277 -141
- package/package.json +1 -1
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +383 -54
- package/ts/chromeCookieWin.ts +230 -0
- package/ts/cli.ts +252 -42
- package/ts/leveldb.ts +154 -0
- package/ts/profiles.ts +73 -19
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +287 -185
- package/ts/slack.ts +31 -5
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
profile
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
274
|
-
" slack auth chrome — import from Chrome
|
|
275
|
-
" slack auth
|
|
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
|
|
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
|
|
369
|
+
if (selected) return cookieFor(store, selected);
|
|
316
370
|
|
|
317
371
|
const localName = readLockfile(localLockfilePath());
|
|
318
|
-
if (localName) return
|
|
372
|
+
if (localName) return cookieFor(store, localName);
|
|
319
373
|
|
|
320
374
|
const globalName = readLockfile(globalLockfilePath());
|
|
321
|
-
if (globalName) return
|
|
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
|
+
}
|