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/README.md +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +184 -138
- package/package.json +1 -1
- package/ts/agent.ts +148 -0
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +185 -16
- package/ts/cli.ts +216 -38
- package/ts/profiles.ts +11 -5
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +180 -102
- package/ts/slack.ts +56 -5
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
|
+
}
|