@drewpayment/mink 0.3.0 → 0.5.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 +235 -11
- package/dist/cli.js +1424 -487
- package/package.json +1 -1
- package/src/cli.ts +23 -1
- package/src/commands/channel.ts +252 -0
- package/src/commands/config.ts +1 -1
- package/src/commands/device.ts +65 -0
- package/src/commands/session-start.ts +16 -0
- package/src/core/channel-process.ts +274 -0
- package/src/core/channel-templates.ts +156 -0
- package/src/core/daemon.ts +63 -2
- package/src/core/device.ts +72 -0
- package/src/core/global-config.ts +72 -11
- package/src/core/paths.ts +20 -0
- package/src/core/sync.ts +12 -0
- package/src/types/channel.ts +16 -0
- package/src/types/config.ts +57 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -89,6 +89,12 @@ switch (command) {
|
|
|
89
89
|
break;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
case "channel": {
|
|
93
|
+
const { channel } = await import("./commands/channel");
|
|
94
|
+
await channel(process.argv.slice(3));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
case "config": {
|
|
93
99
|
const { config } = await import("./commands/config");
|
|
94
100
|
await config(process.argv.slice(3));
|
|
@@ -143,6 +149,12 @@ switch (command) {
|
|
|
143
149
|
break;
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
case "device": {
|
|
153
|
+
const { device } = await import("./commands/device");
|
|
154
|
+
device(process.argv.slice(3));
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
146
158
|
case "bug-search": {
|
|
147
159
|
const { bugSearch } = await import("./commands/bug-search");
|
|
148
160
|
bugSearch(cwd, process.argv.slice(3).join(" "));
|
|
@@ -202,7 +214,10 @@ switch (command) {
|
|
|
202
214
|
console.log(" note search <term> Full-text search across the vault");
|
|
203
215
|
console.log(" skill install Install /mink:note skill for Claude Code");
|
|
204
216
|
console.log();
|
|
205
|
-
console.log("Sync:");
|
|
217
|
+
console.log("Devices & Sync:");
|
|
218
|
+
console.log(" device Show current device info");
|
|
219
|
+
console.log(" device list List all registered devices");
|
|
220
|
+
console.log(" device rename <name> Set a friendly name for this device");
|
|
206
221
|
console.log(" sync Full manual sync (pull then push)");
|
|
207
222
|
console.log(" sync init <remote-url> Connect ~/.mink to a git remote for cross-device sync");
|
|
208
223
|
console.log(" sync status Show sync state (remote, last sync, pending changes)");
|
|
@@ -211,6 +226,13 @@ switch (command) {
|
|
|
211
226
|
console.log(" sync pause / resume Temporarily disable/enable auto-sync");
|
|
212
227
|
console.log(" sync disconnect Remove git tracking (data preserved)");
|
|
213
228
|
console.log();
|
|
229
|
+
console.log("Channels (conversational companion):");
|
|
230
|
+
console.log(" channel setup discord --token <t> Configure Discord bot token");
|
|
231
|
+
console.log(" channel start [platform] Launch a Claude Code session with --channels in the vault");
|
|
232
|
+
console.log(" channel stop Stop the channel session");
|
|
233
|
+
console.log(" channel status Show channel status");
|
|
234
|
+
console.log(" channel logs Tail channel log");
|
|
235
|
+
console.log();
|
|
214
236
|
console.log("Automation & Analysis:");
|
|
215
237
|
console.log(" dashboard [--port=N] Open the real-time web dashboard");
|
|
216
238
|
console.log(" daemon <cmd> Manage the background daemon (start|stop|restart|logs)");
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { resolveConfigValue, setConfigValue } from "../core/global-config";
|
|
2
|
+
import { resolveVaultPath, isVaultInitialized } from "../core/vault";
|
|
3
|
+
import { writeCompanionClaudeMd } from "../core/channel-templates";
|
|
4
|
+
import {
|
|
5
|
+
startChannelProcess,
|
|
6
|
+
stopChannelProcess,
|
|
7
|
+
getChannelStatus,
|
|
8
|
+
getChannelLogs,
|
|
9
|
+
attachChannel,
|
|
10
|
+
} from "../core/channel-process";
|
|
11
|
+
import type { ChannelPlatform } from "../types/channel";
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_PLATFORMS: ChannelPlatform[] = ["discord", "telegram"];
|
|
14
|
+
|
|
15
|
+
function parsePlatform(value: string | undefined): ChannelPlatform | null {
|
|
16
|
+
if (!value) return null;
|
|
17
|
+
if (SUPPORTED_PLATFORMS.includes(value as ChannelPlatform)) {
|
|
18
|
+
return value as ChannelPlatform;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractFlag(args: string[], flag: string): string | undefined {
|
|
24
|
+
const idx = args.findIndex((a) => a === flag || a.startsWith(flag + "="));
|
|
25
|
+
if (idx === -1) return undefined;
|
|
26
|
+
const arg = args[idx];
|
|
27
|
+
if (arg.includes("=")) {
|
|
28
|
+
return arg.slice(arg.indexOf("=") + 1);
|
|
29
|
+
}
|
|
30
|
+
return args[idx + 1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function channel(args: string[]): Promise<void> {
|
|
34
|
+
const subcommand = args[0];
|
|
35
|
+
const rest = args.slice(1);
|
|
36
|
+
|
|
37
|
+
switch (subcommand) {
|
|
38
|
+
case "setup":
|
|
39
|
+
setupChannel(rest);
|
|
40
|
+
break;
|
|
41
|
+
case "start":
|
|
42
|
+
await startChannel(rest);
|
|
43
|
+
break;
|
|
44
|
+
case "stop":
|
|
45
|
+
await stopChannel();
|
|
46
|
+
break;
|
|
47
|
+
case "status":
|
|
48
|
+
showStatus();
|
|
49
|
+
break;
|
|
50
|
+
case "logs":
|
|
51
|
+
showLogs();
|
|
52
|
+
break;
|
|
53
|
+
case "attach":
|
|
54
|
+
doAttach();
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
printUsage();
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setupChannel(args: string[]): void {
|
|
63
|
+
const platform = parsePlatform(args[0]);
|
|
64
|
+
if (!platform) {
|
|
65
|
+
console.error("[mink] missing or invalid platform");
|
|
66
|
+
console.error("Usage: mink channel setup <discord|telegram> --token <token>");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (platform === "telegram") {
|
|
71
|
+
console.error("[mink] telegram setup is not yet supported");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const token = extractFlag(args, "--token");
|
|
76
|
+
|
|
77
|
+
if (!token) {
|
|
78
|
+
console.log("[mink] Discord Channel Setup");
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log("In the Discord Developer Portal (https://discord.com/developers/applications):");
|
|
81
|
+
console.log("");
|
|
82
|
+
console.log(" 1. New Application > give it a name");
|
|
83
|
+
console.log(" 2. Bot > Reset Token > copy the token");
|
|
84
|
+
console.log(" 3. Bot > scroll to Privileged Gateway Intents:");
|
|
85
|
+
console.log(" - Enable MESSAGE CONTENT INTENT (required)");
|
|
86
|
+
console.log(" 4. OAuth2 > URL Generator:");
|
|
87
|
+
console.log(" - Integration Type: Guild Install (NOT User Install)");
|
|
88
|
+
console.log(" - Scopes: bot");
|
|
89
|
+
console.log(" - Bot Permissions: View Channels, Send Messages,");
|
|
90
|
+
console.log(" Send Messages in Threads, Read Message History,");
|
|
91
|
+
console.log(" Attach Files, Add Reactions");
|
|
92
|
+
console.log(" 5. Open the generated URL to invite the bot to a server");
|
|
93
|
+
console.log(" (create a personal server if you just want to DM the bot)");
|
|
94
|
+
console.log("");
|
|
95
|
+
console.log("Then install the channel plugin once inside Claude Code:");
|
|
96
|
+
console.log(" claude");
|
|
97
|
+
console.log(" /plugin install discord@claude-plugins-official");
|
|
98
|
+
console.log(" (exit Claude Code)");
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log("Finally, save your token:");
|
|
101
|
+
console.log(" mink channel setup discord --token <your-token>");
|
|
102
|
+
console.log("");
|
|
103
|
+
console.log("Your token is stored locally in ~/.mink/config.local");
|
|
104
|
+
console.log("and is NOT synced across machines.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!/^[\w.-]{30,}$/.test(token)) {
|
|
109
|
+
console.error("[mink] token format looks invalid — expected a long token string");
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setConfigValue("channel.discord.bot-token", token);
|
|
114
|
+
setConfigValue("channel.discord.enabled", "true");
|
|
115
|
+
setConfigValue("channel.default-platform", "discord");
|
|
116
|
+
|
|
117
|
+
console.log("[mink] Discord bot token saved to config.local");
|
|
118
|
+
console.log("[mink] channel.discord.enabled = true");
|
|
119
|
+
console.log("[mink] channel.default-platform = discord");
|
|
120
|
+
console.log("");
|
|
121
|
+
console.log("Next: mink channel start");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function startChannel(args: string[]): Promise<void> {
|
|
125
|
+
if (!isVaultInitialized()) {
|
|
126
|
+
console.error("[mink] wiki vault is not initialized");
|
|
127
|
+
console.error("Run: mink wiki init");
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const requested = parsePlatform(args[0]);
|
|
132
|
+
const platform =
|
|
133
|
+
requested ??
|
|
134
|
+
parsePlatform(resolveConfigValue("channel.default-platform").value) ??
|
|
135
|
+
"discord";
|
|
136
|
+
|
|
137
|
+
if (platform === "telegram") {
|
|
138
|
+
console.error("[mink] telegram is not yet supported");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const token = resolveConfigValue("channel.discord.bot-token").value;
|
|
143
|
+
if (!token) {
|
|
144
|
+
console.error("[mink] no Discord bot token configured");
|
|
145
|
+
console.error("Run: mink channel setup discord --token <your-token>");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const vaultPath = resolveVaultPath();
|
|
150
|
+
const wrote = writeCompanionClaudeMd(vaultPath, false);
|
|
151
|
+
if (wrote) {
|
|
152
|
+
console.log(`[mink] created companion CLAUDE.md at ${vaultPath}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const skipPermissions =
|
|
156
|
+
resolveConfigValue("channel.skip-permissions").value === "true";
|
|
157
|
+
|
|
158
|
+
let result;
|
|
159
|
+
try {
|
|
160
|
+
result = await startChannelProcess({ vaultPath, platform, token, skipPermissions });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error("[mink] failed to start channel:");
|
|
163
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (result.alreadyRunning) {
|
|
168
|
+
console.log(`[mink] channel is already running (screen session: ${result.session})`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(`[mink] channel started`);
|
|
173
|
+
console.log(`[mink] platform: ${platform}`);
|
|
174
|
+
console.log(`[mink] vault: ${vaultPath}`);
|
|
175
|
+
console.log(`[mink] session: ${result.session} (GNU screen)`);
|
|
176
|
+
console.log("");
|
|
177
|
+
console.log("Next:");
|
|
178
|
+
console.log(" 1. DM your bot on Discord — it replies with a pairing code");
|
|
179
|
+
console.log(" 2. Attach to the Claude Code session: mink channel attach");
|
|
180
|
+
console.log(" 3. Inside the session, run: /discord:access pair <code>");
|
|
181
|
+
console.log(" 4. Then lock down access: /discord:access policy allowlist");
|
|
182
|
+
console.log(" 5. Detach with Ctrl-a d");
|
|
183
|
+
console.log("");
|
|
184
|
+
console.log("See activity: mink channel logs");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function stopChannel(): Promise<void> {
|
|
188
|
+
const result = await stopChannelProcess();
|
|
189
|
+
switch (result) {
|
|
190
|
+
case "not-running":
|
|
191
|
+
console.log("[mink] channel is not running");
|
|
192
|
+
break;
|
|
193
|
+
case "stopped":
|
|
194
|
+
console.log("[mink] channel stopped");
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function showStatus(): void {
|
|
200
|
+
const status = getChannelStatus();
|
|
201
|
+
if (!status) {
|
|
202
|
+
console.log("[mink] channel is not running");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
console.log(`running: yes`);
|
|
206
|
+
console.log(`session: ${status.session}`);
|
|
207
|
+
console.log(`platform: ${status.platform}`);
|
|
208
|
+
console.log(`vault: ${status.vaultPath}`);
|
|
209
|
+
console.log(`started: ${status.startedAt}`);
|
|
210
|
+
console.log(`uptime: ${formatUptime(status.uptime)}`);
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log("Attach: mink channel attach");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function formatUptime(seconds: number): string {
|
|
216
|
+
if (seconds < 60) return `${seconds}s`;
|
|
217
|
+
const mins = Math.floor(seconds / 60);
|
|
218
|
+
if (mins < 60) return `${mins}m ${seconds % 60}s`;
|
|
219
|
+
const hrs = Math.floor(mins / 60);
|
|
220
|
+
return `${hrs}h ${mins % 60}m`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function showLogs(): void {
|
|
224
|
+
const content = getChannelLogs();
|
|
225
|
+
if (content == null) {
|
|
226
|
+
console.log("[mink] channel is not running (no logs to show)");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Strip trailing empty lines for readability
|
|
230
|
+
console.log(content.replace(/\n+$/, ""));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function doAttach(): void {
|
|
234
|
+
const result = attachChannel();
|
|
235
|
+
if (result === "not-running") {
|
|
236
|
+
console.log("[mink] channel is not running");
|
|
237
|
+
console.log("Start it with: mink channel start");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function printUsage(): void {
|
|
242
|
+
console.error("Usage: mink channel <subcommand>");
|
|
243
|
+
console.error("");
|
|
244
|
+
console.error("Subcommands:");
|
|
245
|
+
console.error(" setup <platform> --token <token> Configure a channel (discord|telegram)");
|
|
246
|
+
console.error(" start [platform] Launch channel session (in GNU screen)");
|
|
247
|
+
console.error(" stop Stop channel session");
|
|
248
|
+
console.error(" status Show channel status");
|
|
249
|
+
console.error(" logs Show recent channel output");
|
|
250
|
+
console.error(" attach Attach to the channel's Claude Code session");
|
|
251
|
+
console.error(" (detach with Ctrl-a then d)");
|
|
252
|
+
}
|
package/src/commands/config.ts
CHANGED
|
@@ -69,7 +69,7 @@ export async function config(args: string[]): Promise<void> {
|
|
|
69
69
|
const all = resolveAllConfig();
|
|
70
70
|
console.log("[mink] configuration:");
|
|
71
71
|
for (const entry of all) {
|
|
72
|
-
let line = ` ${entry.key} = ${entry.value} (source: ${entry.source})`;
|
|
72
|
+
let line = ` ${entry.key} = ${entry.value} (${entry.scope}, source: ${entry.source})`;
|
|
73
73
|
if (
|
|
74
74
|
entry.source === "environment variable" &&
|
|
75
75
|
entry.configFileValue !== undefined
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOrCreateDeviceId,
|
|
3
|
+
listDevices,
|
|
4
|
+
setDeviceName,
|
|
5
|
+
} from "../core/device";
|
|
6
|
+
import { hostname, platform } from "os";
|
|
7
|
+
|
|
8
|
+
export function device(args: string[]): void {
|
|
9
|
+
const sub = args[0] ?? "status";
|
|
10
|
+
|
|
11
|
+
switch (sub) {
|
|
12
|
+
case "status": {
|
|
13
|
+
const id = getOrCreateDeviceId();
|
|
14
|
+
const devices = listDevices();
|
|
15
|
+
const current = devices.find((d) => d.id === id);
|
|
16
|
+
console.log("[mink] device info:");
|
|
17
|
+
console.log(` id: ${id}`);
|
|
18
|
+
console.log(` name: ${current?.name ?? hostname()}`);
|
|
19
|
+
console.log(` hostname: ${hostname()}`);
|
|
20
|
+
console.log(` platform: ${platform()}`);
|
|
21
|
+
if (current?.firstSeen) {
|
|
22
|
+
console.log(` first seen: ${current.firstSeen}`);
|
|
23
|
+
}
|
|
24
|
+
if (current?.lastSeen) {
|
|
25
|
+
console.log(` last seen: ${current.lastSeen}`);
|
|
26
|
+
}
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
case "list": {
|
|
31
|
+
const devices = listDevices();
|
|
32
|
+
const currentId = getOrCreateDeviceId();
|
|
33
|
+
if (devices.length === 0) {
|
|
34
|
+
console.log("[mink] no devices registered yet");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log("[mink] registered devices:");
|
|
38
|
+
for (const d of devices) {
|
|
39
|
+
const marker = d.id === currentId ? " (this device)" : "";
|
|
40
|
+
console.log(` ${d.name}${marker}`);
|
|
41
|
+
console.log(` id: ${d.id}`);
|
|
42
|
+
console.log(` hostname: ${d.hostname}`);
|
|
43
|
+
console.log(` platform: ${d.platform}`);
|
|
44
|
+
console.log(` last seen: ${d.lastSeen}`);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case "rename": {
|
|
50
|
+
const name = args.slice(1).join(" ");
|
|
51
|
+
if (!name) {
|
|
52
|
+
console.error("Usage: mink device rename <name>");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
setDeviceName(name);
|
|
56
|
+
console.log(`[mink] device renamed to "${name}"`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
default:
|
|
61
|
+
console.error(`[mink] unknown device subcommand: ${sub}`);
|
|
62
|
+
console.error("Usage: mink device [status|list|rename <name>]");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -7,6 +7,22 @@ import { isWikiEnabled, isVaultInitialized, isInsideVault } from "../core/vault"
|
|
|
7
7
|
import { loadVaultIndex } from "../core/note-index";
|
|
8
8
|
|
|
9
9
|
export function sessionStart(cwd: string): void {
|
|
10
|
+
// Migrate config to shared/local split if needed (before sync pull)
|
|
11
|
+
try {
|
|
12
|
+
const { migrateConfigIfNeeded } = require("../core/global-config");
|
|
13
|
+
migrateConfigIfNeeded();
|
|
14
|
+
} catch {
|
|
15
|
+
// Never crash hooks
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Register/update this device in the registry
|
|
19
|
+
try {
|
|
20
|
+
const { updateDeviceHeartbeat } = require("../core/device");
|
|
21
|
+
updateDeviceHeartbeat();
|
|
22
|
+
} catch {
|
|
23
|
+
// Never crash hooks
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
// Sync pull before session begins (if enabled)
|
|
11
27
|
try {
|
|
12
28
|
const { isSyncInitialized, syncPull } = require("../core/sync");
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
import { channelPidPath, minkRoot } from "./paths";
|
|
5
|
+
import type { ChannelPidFile, ChannelStatus, ChannelPlatform } from "../types/channel";
|
|
6
|
+
|
|
7
|
+
// ── PID File Operations ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function readChannelPidFile(): ChannelPidFile | null {
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(channelPidPath(), "utf-8");
|
|
12
|
+
const data = JSON.parse(raw);
|
|
13
|
+
if (
|
|
14
|
+
data &&
|
|
15
|
+
typeof data.session === "string" &&
|
|
16
|
+
typeof data.platform === "string" &&
|
|
17
|
+
typeof data.startedAt === "string" &&
|
|
18
|
+
typeof data.vaultPath === "string"
|
|
19
|
+
) {
|
|
20
|
+
return data as ChannelPidFile;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeChannelPidFile(data: ChannelPidFile): void {
|
|
29
|
+
const pidPath = channelPidPath();
|
|
30
|
+
mkdirSync(dirname(pidPath), { recursive: true });
|
|
31
|
+
writeFileSync(pidPath, JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeChannelPidFile(): void {
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(channelPidPath());
|
|
37
|
+
} catch {
|
|
38
|
+
// Already removed
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Screen Session Management ───────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const SESSION_PREFIX = "mink-channel-";
|
|
45
|
+
|
|
46
|
+
function sessionName(platform: ChannelPlatform): string {
|
|
47
|
+
return `${SESSION_PREFIX}${platform}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isScreenInstalled(): boolean {
|
|
51
|
+
const result = spawnSync("screen", ["-ls"], { stdio: "ignore" });
|
|
52
|
+
return !result.error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function screenSessionExists(session: string): boolean {
|
|
56
|
+
const result = spawnSync("screen", ["-ls", session], {
|
|
57
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
58
|
+
encoding: "utf-8",
|
|
59
|
+
});
|
|
60
|
+
const output = typeof result.stdout === "string" ? result.stdout : "";
|
|
61
|
+
return new RegExp(`\\d+\\.${session}\\b`).test(output);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shellEscape(s: string): string {
|
|
65
|
+
if (/^[a-zA-Z0-9_@%+=:,./\-]+$/.test(s)) return s;
|
|
66
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Channel Lifecycle ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const PLUGIN_SPECS: Record<ChannelPlatform, string> = {
|
|
72
|
+
discord: "plugin:discord@claude-plugins-official",
|
|
73
|
+
telegram: "plugin:telegram@claude-plugins-official",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const TOKEN_ENV_VARS: Record<ChannelPlatform, string> = {
|
|
77
|
+
discord: "DISCORD_BOT_TOKEN",
|
|
78
|
+
telegram: "TELEGRAM_BOT_TOKEN",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface StartChannelOptions {
|
|
82
|
+
vaultPath: string;
|
|
83
|
+
platform: ChannelPlatform;
|
|
84
|
+
token?: string;
|
|
85
|
+
claudeCommand?: string;
|
|
86
|
+
skipPermissions?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface StartChannelResult {
|
|
90
|
+
session: string;
|
|
91
|
+
alreadyRunning: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function startChannelProcess(opts: StartChannelOptions): Promise<StartChannelResult> {
|
|
95
|
+
if (!isScreenInstalled()) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"GNU screen is required but was not found on PATH.\n" +
|
|
98
|
+
"macOS: screen is pre-installed — check your shell environment.\n" +
|
|
99
|
+
"Linux: install with `sudo apt install screen` (or your package manager)."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const session = sessionName(opts.platform);
|
|
104
|
+
|
|
105
|
+
if (screenSessionExists(session)) {
|
|
106
|
+
writeChannelPidFile({
|
|
107
|
+
session,
|
|
108
|
+
platform: opts.platform,
|
|
109
|
+
startedAt: new Date().toISOString(),
|
|
110
|
+
vaultPath: opts.vaultPath,
|
|
111
|
+
});
|
|
112
|
+
return { session, alreadyRunning: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const claudeCmd = opts.claudeCommand ?? "claude";
|
|
116
|
+
const pluginSpec = PLUGIN_SPECS[opts.platform];
|
|
117
|
+
const tokenEnvVar = TOKEN_ENV_VARS[opts.platform];
|
|
118
|
+
|
|
119
|
+
const claudeFlags = ["--channels", shellEscape(pluginSpec)];
|
|
120
|
+
if (opts.skipPermissions) {
|
|
121
|
+
claudeFlags.push("--dangerously-skip-permissions");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parts: string[] = [];
|
|
125
|
+
parts.push(`cd ${shellEscape(opts.vaultPath)}`);
|
|
126
|
+
if (opts.token) {
|
|
127
|
+
parts.push(`export ${tokenEnvVar}=${shellEscape(opts.token)}`);
|
|
128
|
+
}
|
|
129
|
+
parts.push(`exec ${shellEscape(claudeCmd)} ${claudeFlags.join(" ")}`);
|
|
130
|
+
const innerCmd = parts.join("; ");
|
|
131
|
+
|
|
132
|
+
const result = spawnSync(
|
|
133
|
+
"screen",
|
|
134
|
+
// -T screen-256color: advertise 256-color terminal to inner programs.
|
|
135
|
+
// Default screen TERM is `screen` (8 colors) and makes Claude Code render washed-out.
|
|
136
|
+
["-T", "screen-256color", "-dmS", session, "bash", "-c", innerCmd],
|
|
137
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (result.status !== 0) {
|
|
141
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
142
|
+
throw new Error(
|
|
143
|
+
`screen failed to create session (exit ${result.status}): ${stderr || "(no output)"}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Give screen/claude a moment to start; verify the session is still alive
|
|
148
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
149
|
+
|
|
150
|
+
if (!screenSessionExists(session)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"channel session died immediately after starting. " +
|
|
153
|
+
"This usually means `claude` failed to launch. Check:\n" +
|
|
154
|
+
" • Is `claude` on your PATH? Run `which claude`.\n" +
|
|
155
|
+
" • Have you installed the plugin? Run `claude` then `/plugin install discord@claude-plugins-official`.\n" +
|
|
156
|
+
" • Try running the command manually to see the error:\n" +
|
|
157
|
+
` cd ${opts.vaultPath} && claude --channels ${pluginSpec}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
writeChannelPidFile({
|
|
162
|
+
session,
|
|
163
|
+
platform: opts.platform,
|
|
164
|
+
startedAt: new Date().toISOString(),
|
|
165
|
+
vaultPath: opts.vaultPath,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return { session, alreadyRunning: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function stopChannelProcess(): Promise<"stopped" | "not-running"> {
|
|
172
|
+
const pidData = readChannelPidFile();
|
|
173
|
+
if (!pidData) {
|
|
174
|
+
return "not-running";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!screenSessionExists(pidData.session)) {
|
|
178
|
+
removeChannelPidFile();
|
|
179
|
+
return "not-running";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
spawnSync("screen", ["-S", pidData.session, "-X", "quit"], { stdio: "ignore" });
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < 30; i++) {
|
|
185
|
+
if (!screenSessionExists(pidData.session)) {
|
|
186
|
+
removeChannelPidFile();
|
|
187
|
+
return "stopped";
|
|
188
|
+
}
|
|
189
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Session lingered — force clean up our tracking file
|
|
193
|
+
removeChannelPidFile();
|
|
194
|
+
return "stopped";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function isChannelRunning(): boolean {
|
|
198
|
+
const pidData = readChannelPidFile();
|
|
199
|
+
if (!pidData) return false;
|
|
200
|
+
if (!screenSessionExists(pidData.session)) {
|
|
201
|
+
removeChannelPidFile();
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getChannelStatus(): ChannelStatus | null {
|
|
208
|
+
const pidData = readChannelPidFile();
|
|
209
|
+
if (!pidData) return null;
|
|
210
|
+
if (!screenSessionExists(pidData.session)) {
|
|
211
|
+
removeChannelPidFile();
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const startedMs = Date.parse(pidData.startedAt);
|
|
215
|
+
const uptimeSec = Number.isFinite(startedMs)
|
|
216
|
+
? Math.max(0, Math.floor((Date.now() - startedMs) / 1000))
|
|
217
|
+
: 0;
|
|
218
|
+
return {
|
|
219
|
+
session: pidData.session,
|
|
220
|
+
platform: pidData.platform,
|
|
221
|
+
startedAt: pidData.startedAt,
|
|
222
|
+
vaultPath: pidData.vaultPath,
|
|
223
|
+
uptime: uptimeSec,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getChannelLogs(): string | null {
|
|
228
|
+
const pidData = readChannelPidFile();
|
|
229
|
+
if (!pidData) return null;
|
|
230
|
+
if (!screenSessionExists(pidData.session)) return null;
|
|
231
|
+
|
|
232
|
+
const tmpPath = join(minkRoot(), `.channel-capture-${Date.now()}-${process.pid}.txt`);
|
|
233
|
+
|
|
234
|
+
const result = spawnSync(
|
|
235
|
+
"screen",
|
|
236
|
+
["-S", pidData.session, "-X", "hardcopy", "-h", tmpPath],
|
|
237
|
+
{ stdio: "ignore" }
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (result.status !== 0) return null;
|
|
241
|
+
|
|
242
|
+
// screen writes asynchronously; give it a brief moment
|
|
243
|
+
for (let i = 0; i < 20; i++) {
|
|
244
|
+
if (existsSync(tmpPath)) break;
|
|
245
|
+
const delayUntil = Date.now() + 50;
|
|
246
|
+
while (Date.now() < delayUntil) {
|
|
247
|
+
/* busy wait — 50ms */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const content = readFileSync(tmpPath, "utf-8");
|
|
253
|
+
try {
|
|
254
|
+
unlinkSync(tmpPath);
|
|
255
|
+
} catch {
|
|
256
|
+
/* ignore */
|
|
257
|
+
}
|
|
258
|
+
return content;
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function attachChannel(): "attached" | "not-running" {
|
|
265
|
+
const pidData = readChannelPidFile();
|
|
266
|
+
if (!pidData) return "not-running";
|
|
267
|
+
if (!screenSessionExists(pidData.session)) {
|
|
268
|
+
removeChannelPidFile();
|
|
269
|
+
return "not-running";
|
|
270
|
+
}
|
|
271
|
+
// Hand terminal to screen. Returns when user detaches (Ctrl-a then d).
|
|
272
|
+
spawnSync("screen", ["-r", pidData.session], { stdio: "inherit" });
|
|
273
|
+
return "attached";
|
|
274
|
+
}
|