claude-slack-channel-bots 0.3.0 → 0.4.1
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 +7 -1
- package/package.json +1 -1
- package/src/cli.ts +11 -8
- package/src/config.ts +11 -0
- package/src/cozempic.ts +150 -0
- package/src/registry.ts +22 -0
- package/src/server.ts +50 -27
- package/src/session-manager.ts +12 -4
- package/src/tokens.ts +13 -0
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ See the sections below for manual configuration details if you prefer not to use
|
|
|
42
42
|
- `ss` from [iproute2](https://github.com/iproute2/iproute2) on your `PATH` (required for session ID discovery; pre-installed on most Linux distributions)
|
|
43
43
|
- `curl` and `jq` on your `PATH` (required for the permission relay hooks)
|
|
44
44
|
- Slack workspace admin access (to create and configure the Slack app)
|
|
45
|
+
- **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — enables session file cleaning before `--resume` for faster load times
|
|
45
46
|
|
|
46
47
|
---
|
|
47
48
|
|
|
@@ -57,6 +58,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
|
|
|
57
58
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
|
|
58
59
|
| `SLACK_STATE_DIR` | Override the directory where `routing.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
|
|
59
60
|
| `SLACK_ACCESS_MODE` | Set to `static` to load `access.json` once at startup and cache it for the lifetime of the process rather than re-reading it on every event. Useful in high-throughput environments where disk reads are a concern. |
|
|
61
|
+
| `SLACK_DRY_RUN` | Set to `1` to start the server without Slack credentials. Token validation is skipped, Socket Mode and `web.auth.test()` are not called, and MCP tool calls (`reply`, `react`, etc.) are logged instead of sent. Useful for integration testing. |
|
|
60
62
|
|
|
61
63
|
Shell profile example:
|
|
62
64
|
|
|
@@ -66,6 +68,8 @@ export SLACK_APP_TOKEN=xapp-your-app-token
|
|
|
66
68
|
# Optional overrides:
|
|
67
69
|
export SLACK_STATE_DIR=~/.config/slack-channel-bots
|
|
68
70
|
export SLACK_ACCESS_MODE=static
|
|
71
|
+
# Dry-run mode (no Slack credentials needed):
|
|
72
|
+
export SLACK_DRY_RUN=1
|
|
69
73
|
```
|
|
70
74
|
|
|
71
75
|
---
|
|
@@ -92,7 +96,8 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
92
96
|
"health_check_interval": 120,
|
|
93
97
|
"exit_timeout": 120,
|
|
94
98
|
"stop_timeout": 30,
|
|
95
|
-
"mcp_config_path": "~/.claude/slack-mcp.json"
|
|
99
|
+
"mcp_config_path": "~/.claude/slack-mcp.json",
|
|
100
|
+
"cozempic_prescription": "standard"
|
|
96
101
|
}
|
|
97
102
|
```
|
|
98
103
|
|
|
@@ -111,6 +116,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
111
116
|
| `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
|
|
112
117
|
| `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
|
|
113
118
|
| `append_system_prompt_file` | string | — | Path to a file appended to every managed session's system prompt via `--append-system-prompt-file`. Missing file silently skipped. See `skills/EXAMPLE_CLAUDE.md` for a template. |
|
|
119
|
+
| `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
|
|
114
120
|
|
|
115
121
|
---
|
|
116
122
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning, sessionName
|
|
|
18
18
|
import { readSessions, type SessionsMap } from './sessions.ts'
|
|
19
19
|
import { loadConfig as configLoadConfig, type RoutingConfig } from './config.ts'
|
|
20
20
|
import { initLogging } from './logging.ts'
|
|
21
|
+
import { isDryRun } from './tokens.ts'
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Injectable dependency interface
|
|
@@ -92,14 +93,16 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
92
93
|
deps.exit(1)
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
// Check required Slack tokens
|
|
96
|
-
if (!
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
// Check required Slack tokens (skipped in dry-run mode)
|
|
97
|
+
if (!isDryRun()) {
|
|
98
|
+
if (!deps.env['SLACK_BOT_TOKEN']) {
|
|
99
|
+
console.error('missing prerequisite: SLACK_BOT_TOKEN environment variable')
|
|
100
|
+
deps.exit(1)
|
|
101
|
+
}
|
|
102
|
+
if (!deps.env['SLACK_APP_TOKEN']) {
|
|
103
|
+
console.error('missing prerequisite: SLACK_APP_TOKEN environment variable')
|
|
104
|
+
deps.exit(1)
|
|
105
|
+
}
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
// Check routing.json exists
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolve } from 'path'
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
19
|
export const MCP_SERVER_NAME = 'slack-channel-router'
|
|
20
|
+
export const ALLOWED_PRESCRIPTIONS = ['gentle', 'standard', 'aggressive']
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Types
|
|
@@ -41,6 +42,7 @@ export interface RoutingConfigInput {
|
|
|
41
42
|
stop_timeout?: number
|
|
42
43
|
mcp_config_path?: string
|
|
43
44
|
append_system_prompt_file?: string
|
|
45
|
+
cozempic_prescription?: string
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** Validated, fully-resolved routing configuration with all defaults applied. */
|
|
@@ -56,6 +58,7 @@ export interface RoutingConfig {
|
|
|
56
58
|
stop_timeout: number
|
|
57
59
|
mcp_config_path: string
|
|
58
60
|
append_system_prompt_file?: string
|
|
61
|
+
cozempic_prescription: string
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// ---------------------------------------------------------------------------
|
|
@@ -79,6 +82,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
|
|
|
79
82
|
stop_timeout: input.stop_timeout ?? 30,
|
|
80
83
|
mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
|
|
81
84
|
append_system_prompt_file: input.append_system_prompt_file,
|
|
85
|
+
cozempic_prescription: input.cozempic_prescription ?? 'standard',
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -152,6 +156,13 @@ export function validateConfig(config: RoutingConfig): void {
|
|
|
152
156
|
)
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
// cozempic_prescription must be one of the allowed values
|
|
160
|
+
if (!ALLOWED_PRESCRIPTIONS.includes(config.cozempic_prescription)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Routing config validation error: cozempic_prescription "${config.cozempic_prescription}" is invalid. Allowed values are: ${ALLOWED_PRESCRIPTIONS.join(', ')}.`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
// default_dm_session must reference an existing route CWD
|
|
156
167
|
if (config.default_dm_session !== undefined) {
|
|
157
168
|
if (!seen.has(config.default_dm_session)) {
|
package/src/cozempic.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cozempic.ts — Session cleaning via the cozempic CLI.
|
|
3
|
+
*
|
|
4
|
+
* Provides PATH availability checking, JSONL path resolution, file size
|
|
5
|
+
* inspection, and async session cleaning using `cozempic treat`.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'child_process'
|
|
11
|
+
import { statSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
13
|
+
import { ALLOWED_PRESCRIPTIONS } from './config.ts'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// PATH availability check
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let cozempicAvailable: boolean | null = null
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks whether `cozempic` is on PATH by spawning `which cozempic`.
|
|
23
|
+
* Sets the module-scoped flag and logs the result to stderr.
|
|
24
|
+
*/
|
|
25
|
+
export async function checkCozempicAvailable(): Promise<void> {
|
|
26
|
+
return new Promise<void>((resolve) => {
|
|
27
|
+
let settled = false
|
|
28
|
+
const done = (available: boolean, msg: string) => {
|
|
29
|
+
if (settled) return
|
|
30
|
+
settled = true
|
|
31
|
+
cozempicAvailable = available
|
|
32
|
+
console.error(msg)
|
|
33
|
+
resolve()
|
|
34
|
+
}
|
|
35
|
+
const proc = spawn('which', ['cozempic'])
|
|
36
|
+
proc.on('error', () => {
|
|
37
|
+
done(false, '[slack] Warning: cozempic not found on PATH — session cleaning disabled')
|
|
38
|
+
})
|
|
39
|
+
proc.on('close', (code) => {
|
|
40
|
+
if (code === 0) {
|
|
41
|
+
done(true, '[slack] cozempic available')
|
|
42
|
+
} else {
|
|
43
|
+
done(false, '[slack] Warning: cozempic not found on PATH — session cleaning disabled')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns true if cozempic was found on PATH during the last availability check. */
|
|
50
|
+
export function getCozempicAvailable(): boolean {
|
|
51
|
+
return cozempicAvailable === true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resets the availability flag to null (for testing). */
|
|
55
|
+
export function _resetCozempicAvailable(): void {
|
|
56
|
+
cozempicAvailable = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// JSONL path resolution
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds the absolute JSONL path for a given cwd and session ID.
|
|
65
|
+
* Pure function — no I/O, no validation.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveJsonlPath(cwd: string, sessionId: string): string {
|
|
68
|
+
const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
69
|
+
return `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// File size helper
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the size of the file at `path` in bytes, or null if the file
|
|
78
|
+
* cannot be stat'd (missing, permission error, etc.).
|
|
79
|
+
*/
|
|
80
|
+
export function readFileSizeBytes(path: string): number | null {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(path).size
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Session cleaning
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export type CleanSessionFn = (sessionId: string, cwd: string, prescription: string) => Promise<void>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs `cozempic treat <sessionId> -rx <prescription> --execute` against the
|
|
96
|
+
* session's JSONL file. Always resolves — never rejects.
|
|
97
|
+
*
|
|
98
|
+
* Skips if the JSONL is missing or empty. Streams cozempic output to stderr
|
|
99
|
+
* with the `[slack] cozempic:` prefix and logs before/after file sizes.
|
|
100
|
+
*/
|
|
101
|
+
export async function cleanSession(sessionId: string, cwd: string, prescription: string): Promise<void> {
|
|
102
|
+
if (!ALLOWED_PRESCRIPTIONS.includes(prescription)) {
|
|
103
|
+
console.error(`[slack] cozempic: invalid prescription "${prescription}" — skipping clean session=${sessionId}`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const path = resolveJsonlPath(cwd, sessionId)
|
|
107
|
+
const beforeSize = readFileSizeBytes(path)
|
|
108
|
+
|
|
109
|
+
if (beforeSize === null) {
|
|
110
|
+
console.error(`[slack] cozempic: JSONL not found — skipping clean session=${sessionId}`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (beforeSize === 0) {
|
|
115
|
+
console.error(`[slack] cozempic: JSONL empty — skipping clean session=${sessionId}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.error(`[slack] cozempic: cleaning started session=${sessionId} size=${beforeSize}`)
|
|
120
|
+
|
|
121
|
+
return new Promise<void>((resolve) => {
|
|
122
|
+
const proc = spawn('cozempic', ['treat', sessionId, '-rx', prescription, '--execute'])
|
|
123
|
+
|
|
124
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
125
|
+
for (const line of chunk.toString().split('\n')) {
|
|
126
|
+
if (line.length > 0) console.error(`[slack] cozempic: ${line}`)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
131
|
+
for (const line of chunk.toString().split('\n')) {
|
|
132
|
+
if (line.length > 0) console.error(`[slack] cozempic: ${line}`)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
proc.on('error', (err) => {
|
|
137
|
+
console.error(`[slack] cozempic: spawn error session=${sessionId}: ${err.message}`)
|
|
138
|
+
resolve()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
proc.on('close', (code) => {
|
|
142
|
+
const afterSize = readFileSizeBytes(path)
|
|
143
|
+
if (code !== 0 && code !== null) {
|
|
144
|
+
console.error(`[slack] cozempic: exit code ${code} session=${sessionId}`)
|
|
145
|
+
}
|
|
146
|
+
console.error(`[slack] cozempic: cleaning done session=${sessionId} size=${afterSize ?? 'unknown'}`)
|
|
147
|
+
resolve()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
17
17
|
import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
|
|
18
|
+
import { isDryRun } from './tokens.ts'
|
|
18
19
|
import { getPeerPidByPort, getSessionIdForPid } from './peer-pid.ts'
|
|
19
20
|
import { readSessions, writeSessions, type SessionsMap } from './sessions.ts'
|
|
20
21
|
|
|
@@ -516,6 +517,11 @@ export function createSessionServer(
|
|
|
516
517
|
// Per-session outbound gate (t2.c1r.zk.qm)
|
|
517
518
|
assertOutboundAllowed(chatId, entry.deliveredChannels)
|
|
518
519
|
|
|
520
|
+
if (isDryRun()) {
|
|
521
|
+
console.error(`[slack] dry-run: reply to ${chatId} (${text.length} chars)`)
|
|
522
|
+
return { content: [{ type: 'text', text: `[dry-run] Would send message to ${chatId}` }] }
|
|
523
|
+
}
|
|
524
|
+
|
|
519
525
|
const access = getAccess()
|
|
520
526
|
const limit = access.textChunkLimit || DEFAULT_CHUNK_LIMIT
|
|
521
527
|
const mode = access.chunkMode || 'newline'
|
|
@@ -575,6 +581,10 @@ export function createSessionServer(
|
|
|
575
581
|
// ---------------------------------------------------------------------
|
|
576
582
|
case 'react': {
|
|
577
583
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
584
|
+
if (isDryRun()) {
|
|
585
|
+
console.error(`[slack] dry-run: react :${args.emoji}: on ${args.message_id}`)
|
|
586
|
+
return { content: [{ type: 'text', text: `[dry-run] Would react :${args.emoji}: to ${args.message_id}` }] }
|
|
587
|
+
}
|
|
578
588
|
await web.reactions.add({
|
|
579
589
|
channel: args.chat_id,
|
|
580
590
|
timestamp: args.message_id,
|
|
@@ -590,6 +600,10 @@ export function createSessionServer(
|
|
|
590
600
|
// ---------------------------------------------------------------------
|
|
591
601
|
case 'edit_message': {
|
|
592
602
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
603
|
+
if (isDryRun()) {
|
|
604
|
+
console.error(`[slack] dry-run: edit_message ${args.message_id} in ${args.chat_id}`)
|
|
605
|
+
return { content: [{ type: 'text', text: `[dry-run] Would edit message ${args.message_id}` }] }
|
|
606
|
+
}
|
|
593
607
|
await web.chat.update({
|
|
594
608
|
channel: args.chat_id,
|
|
595
609
|
ts: args.message_id,
|
|
@@ -605,6 +619,10 @@ export function createSessionServer(
|
|
|
605
619
|
// ---------------------------------------------------------------------
|
|
606
620
|
case 'fetch_messages': {
|
|
607
621
|
assertOutboundAllowed(args.channel, entry.deliveredChannels)
|
|
622
|
+
if (isDryRun()) {
|
|
623
|
+
console.error(`[slack] dry-run: fetch_messages from ${args.channel}`)
|
|
624
|
+
return { content: [{ type: 'text', text: `[dry-run] Would fetch messages from ${args.channel}` }] }
|
|
625
|
+
}
|
|
608
626
|
const channel: string = args.channel
|
|
609
627
|
const limit = Math.min(args.limit || 20, 100)
|
|
610
628
|
const threadTs: string | undefined = args.thread_ts
|
|
@@ -646,6 +664,10 @@ export function createSessionServer(
|
|
|
646
664
|
// ---------------------------------------------------------------------
|
|
647
665
|
case 'download_attachment': {
|
|
648
666
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
667
|
+
if (isDryRun()) {
|
|
668
|
+
console.error(`[slack] dry-run: download_attachment from ${args.chat_id} msg=${args.message_id}`)
|
|
669
|
+
return { content: [{ type: 'text', text: `[dry-run] Would download attachments from ${args.message_id}` }] }
|
|
670
|
+
}
|
|
649
671
|
const channel: string = args.chat_id
|
|
650
672
|
const messageTs: string = args.message_id
|
|
651
673
|
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { loadConfig, expandTilde, type RoutingConfig, MCP_SERVER_NAME } from './
|
|
|
42
42
|
import { readSessions, writeSessions, rotateSessions } from './sessions.ts'
|
|
43
43
|
import { defaultTmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
44
44
|
import { startupSessionManager, launchSession } from './session-manager.ts'
|
|
45
|
+
import { cleanSession, getCozempicAvailable } from './cozempic.ts'
|
|
45
46
|
import {
|
|
46
47
|
initRestart,
|
|
47
48
|
scheduleRestart,
|
|
@@ -51,7 +52,7 @@ import {
|
|
|
51
52
|
hasReachedMaxFailures,
|
|
52
53
|
} from './restart.ts'
|
|
53
54
|
import { initHealthCheck, startHealthCheck, stopHealthCheck } from './health-check.ts'
|
|
54
|
-
import { loadTokens } from './tokens.ts'
|
|
55
|
+
import { loadTokens, isDryRun } from './tokens.ts'
|
|
55
56
|
import { checkPidConflict, writePidFile, removePidFile } from './pid.ts'
|
|
56
57
|
import { trackAck, consumeAck } from './ack-tracker.ts'
|
|
57
58
|
import {
|
|
@@ -946,17 +947,22 @@ export async function main(): Promise<void> {
|
|
|
946
947
|
}
|
|
947
948
|
}
|
|
948
949
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
950
|
+
if (isDryRun()) {
|
|
951
|
+
console.error('[slack] Running in dry-run mode — Slack disabled')
|
|
952
|
+
botUserId = 'U000DRY'
|
|
953
|
+
} else {
|
|
954
|
+
// Resolve bot user ID
|
|
955
|
+
try {
|
|
956
|
+
const auth = await web.auth.test()
|
|
957
|
+
botUserId = (auth.user_id as string) || ''
|
|
958
|
+
} catch (err) {
|
|
959
|
+
console.error('[slack] Failed to resolve bot user ID:', err)
|
|
960
|
+
}
|
|
956
961
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
962
|
+
// Connect Socket Mode
|
|
963
|
+
await socket.start()
|
|
964
|
+
console.error('[slack] Socket Mode connected')
|
|
965
|
+
}
|
|
960
966
|
|
|
961
967
|
// Propagate resolved port to tool deps for peer PID discovery
|
|
962
968
|
sessionToolDeps.serverPort = mcpPort
|
|
@@ -1103,6 +1109,17 @@ export async function main(): Promise<void> {
|
|
|
1103
1109
|
// Generate unique request ID
|
|
1104
1110
|
const requestId = crypto.randomUUID()
|
|
1105
1111
|
|
|
1112
|
+
// Register pending entry BEFORE posting to Slack to avoid race condition:
|
|
1113
|
+
// If the user clicks Allow/Deny before postMessage returns, the interactive
|
|
1114
|
+
// handler needs the entry to already exist in pendingPermissions.
|
|
1115
|
+
pendingPermissions.set(requestId, {
|
|
1116
|
+
requestId,
|
|
1117
|
+
channelId: matchedChannelId,
|
|
1118
|
+
messageTs: '',
|
|
1119
|
+
toolName: tool_name,
|
|
1120
|
+
waiters: [],
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1106
1123
|
// Post Block Kit message to channel
|
|
1107
1124
|
let messageTs: string
|
|
1108
1125
|
try {
|
|
@@ -1114,6 +1131,7 @@ export async function main(): Promise<void> {
|
|
|
1114
1131
|
})
|
|
1115
1132
|
messageTs = postResult.ts as string
|
|
1116
1133
|
} catch (err) {
|
|
1134
|
+
pendingPermissions.delete(requestId)
|
|
1117
1135
|
console.error('[slack] /permission: chat.postMessage failed:', err)
|
|
1118
1136
|
return new Response(JSON.stringify({ error: 'Failed to post message' }), {
|
|
1119
1137
|
status: 500,
|
|
@@ -1121,14 +1139,9 @@ export async function main(): Promise<void> {
|
|
|
1121
1139
|
})
|
|
1122
1140
|
}
|
|
1123
1141
|
|
|
1124
|
-
//
|
|
1125
|
-
pendingPermissions.
|
|
1126
|
-
|
|
1127
|
-
channelId: matchedChannelId,
|
|
1128
|
-
messageTs,
|
|
1129
|
-
toolName: tool_name,
|
|
1130
|
-
waiters: [],
|
|
1131
|
-
})
|
|
1142
|
+
// Update with the real message timestamp (needed for chat.update on decision)
|
|
1143
|
+
const pending = pendingPermissions.get(requestId)
|
|
1144
|
+
if (pending) pending.messageTs = messageTs
|
|
1132
1145
|
|
|
1133
1146
|
return new Response(JSON.stringify({ requestId }), {
|
|
1134
1147
|
status: 200,
|
|
@@ -1268,6 +1281,17 @@ export async function main(): Promise<void> {
|
|
|
1268
1281
|
},
|
|
1269
1282
|
]
|
|
1270
1283
|
|
|
1284
|
+
// Register pending entry BEFORE posting to Slack to avoid race condition:
|
|
1285
|
+
// If the user clicks a button before postMessage returns, the interactive
|
|
1286
|
+
// handler needs the entry to already exist in pendingQuestions.
|
|
1287
|
+
pendingQuestions.set(requestId, {
|
|
1288
|
+
requestId,
|
|
1289
|
+
channelId: matchedChannelId,
|
|
1290
|
+
messageTs: '',
|
|
1291
|
+
question: question as string,
|
|
1292
|
+
waiters: [],
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1271
1295
|
let messageTs: string
|
|
1272
1296
|
try {
|
|
1273
1297
|
const postResult = await web.chat.postMessage({
|
|
@@ -1277,6 +1301,7 @@ export async function main(): Promise<void> {
|
|
|
1277
1301
|
})
|
|
1278
1302
|
messageTs = postResult.ts as string
|
|
1279
1303
|
} catch (err) {
|
|
1304
|
+
pendingQuestions.delete(requestId)
|
|
1280
1305
|
console.error('[slack] /ask: chat.postMessage failed:', err)
|
|
1281
1306
|
return new Response(JSON.stringify({ error: 'Failed to post message' }), {
|
|
1282
1307
|
status: 500,
|
|
@@ -1284,13 +1309,9 @@ export async function main(): Promise<void> {
|
|
|
1284
1309
|
})
|
|
1285
1310
|
}
|
|
1286
1311
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
messageTs,
|
|
1291
|
-
question: question as string,
|
|
1292
|
-
waiters: [],
|
|
1293
|
-
})
|
|
1312
|
+
// Update with the real message timestamp
|
|
1313
|
+
const pendingQ = pendingQuestions.get(requestId)
|
|
1314
|
+
if (pendingQ) pendingQ.messageTs = messageTs
|
|
1294
1315
|
|
|
1295
1316
|
return new Response(JSON.stringify({ requestId }), {
|
|
1296
1317
|
status: 200,
|
|
@@ -1423,7 +1444,9 @@ export async function main(): Promise<void> {
|
|
|
1423
1444
|
const resolvedSessionId = stored !== 'pending' ? stored : undefined
|
|
1424
1445
|
const record = await launchSession(
|
|
1425
1446
|
channelId, cwd, routingConfig, defaultTmuxClient,
|
|
1426
|
-
resolvedSessionId !== undefined
|
|
1447
|
+
resolvedSessionId !== undefined
|
|
1448
|
+
? { sessionId: resolvedSessionId, cleanSession: getCozempicAvailable() ? cleanSession : undefined }
|
|
1449
|
+
: undefined,
|
|
1427
1450
|
)
|
|
1428
1451
|
if (record) {
|
|
1429
1452
|
const sessions = readSessions()
|
package/src/session-manager.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { accessSync, existsSync, constants } from 'fs'
|
|
13
|
-
import { homedir } from 'node:os'
|
|
14
13
|
import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
14
|
+
import { type CleanSessionFn, checkCozempicAvailable, getCozempicAvailable, cleanSession as defaultCleanSession, resolveJsonlPath } from './cozempic.ts'
|
|
15
15
|
import { type SessionsMap, type SessionRecord } from './sessions.ts'
|
|
16
16
|
import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
|
|
17
17
|
|
|
@@ -26,8 +26,7 @@ import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
|
|
|
26
26
|
*/
|
|
27
27
|
export function jsonlExistsForSession(cwd: string, sessionId: string): boolean {
|
|
28
28
|
if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) return false
|
|
29
|
-
const
|
|
30
|
-
const path = `${homedir()}/.claude/projects/${slug}/${sessionId}.jsonl`
|
|
29
|
+
const path = resolveJsonlPath(cwd, sessionId)
|
|
31
30
|
return existsSync(path)
|
|
32
31
|
}
|
|
33
32
|
|
|
@@ -40,6 +39,8 @@ export interface LaunchOptions {
|
|
|
40
39
|
pollTimeout?: number
|
|
41
40
|
/** Claude session UUID to resume. When provided, --resume <id> is appended to the CLI command. */
|
|
42
41
|
sessionId?: string
|
|
42
|
+
/** Optional session cleaning function to run before --resume launches. */
|
|
43
|
+
cleanSession?: CleanSessionFn
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface SessionStateResult {
|
|
@@ -155,6 +156,11 @@ export async function launchSession(
|
|
|
155
156
|
await tmuxClient.newSession(name, cwd)
|
|
156
157
|
console.error(`[slack] Session created: ${name} (cwd="${cwd}")`)
|
|
157
158
|
|
|
159
|
+
// Clean JSONL before resuming (if a cleanSession fn was provided)
|
|
160
|
+
if (resumeSessionId && resumeSessionId !== 'pending' && options?.cleanSession) {
|
|
161
|
+
await options.cleanSession(resumeSessionId, cwd, routingConfig.cozempic_prescription)
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
// Attempt launch (with --resume if sessionId provided)
|
|
159
165
|
let result = await attemptLaunch(resumeSessionId, name)
|
|
160
166
|
|
|
@@ -227,6 +233,8 @@ export async function startupSessionManager(
|
|
|
227
233
|
|
|
228
234
|
console.error(`[slack] startupSessionManager: storedSessions=${JSON.stringify(storedSessions)}`)
|
|
229
235
|
|
|
236
|
+
await checkCozempicAvailable()
|
|
237
|
+
|
|
230
238
|
const routeEntries = Object.entries(routingConfig.routes)
|
|
231
239
|
const concurrency = options?.concurrency ?? 3
|
|
232
240
|
const startupTimeout = options?.startupTimeout ?? 60_000
|
|
@@ -294,7 +302,7 @@ export async function startupSessionManager(
|
|
|
294
302
|
console.error(`[slack] Dead/missing process with stored session ID — resuming: channel=${channelId} session=${name} sessionId=${storedSessionId}`)
|
|
295
303
|
const record = await launchSession(
|
|
296
304
|
channelId, route.cwd, routingConfig, tmuxClient,
|
|
297
|
-
{ ...launchOpts, sessionId: storedSessionId },
|
|
305
|
+
{ ...launchOpts, sessionId: storedSessionId, cleanSession: getCozempicAvailable() ? defaultCleanSession : undefined },
|
|
298
306
|
)
|
|
299
307
|
const elapsed = Date.now() - routeStart
|
|
300
308
|
if (record !== null) {
|
package/src/tokens.ts
CHANGED
|
@@ -7,11 +7,24 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Dry-run detection
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function isDryRun(): boolean {
|
|
15
|
+
const val = (process.env['SLACK_DRY_RUN'] ?? '').toLowerCase()
|
|
16
|
+
return val === '1' || val === 'true' || val === 'yes'
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
// ---------------------------------------------------------------------------
|
|
11
20
|
// Token loading
|
|
12
21
|
// ---------------------------------------------------------------------------
|
|
13
22
|
|
|
14
23
|
export function loadTokens(): { botToken: string; appToken: string } {
|
|
24
|
+
if (isDryRun()) {
|
|
25
|
+
return { botToken: 'xoxb-dry-run', appToken: 'xapp-dry-run' }
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
const botToken = process.env['SLACK_BOT_TOKEN'] ?? ''
|
|
16
29
|
const appToken = process.env['SLACK_APP_TOKEN'] ?? ''
|
|
17
30
|
|