grok-telegram-bot 2.0.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/.env.example +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS service controller — installs a launchd LaunchAgent that runs at login
|
|
3
|
+
* and is kept alive automatically.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { runSafe } from "./platform.js";
|
|
9
|
+
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const LABEL = "com.grok.telegrambot";
|
|
12
|
+
|
|
13
|
+
function plistPath(): string {
|
|
14
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const macosController: ServiceController = {
|
|
18
|
+
platform: "macos",
|
|
19
|
+
|
|
20
|
+
async install(spec) {
|
|
21
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
22
|
+
mkdirSync(spec.logsDir, { recursive: true });
|
|
23
|
+
const path = plistPath();
|
|
24
|
+
runSafe("launchctl", ["unload", "-w", path]); // ignore if not loaded
|
|
25
|
+
writeFileSync(path, plist(spec), "utf-8");
|
|
26
|
+
const r = runSafe("launchctl", ["load", "-w", path]);
|
|
27
|
+
return r.ok ? ok(`Installed and loaded LaunchAgent "${LABEL}".`) : fail(r.out);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async uninstall() {
|
|
31
|
+
runSafe("launchctl", ["unload", "-w", plistPath()]);
|
|
32
|
+
rmSync(plistPath(), { force: true });
|
|
33
|
+
return ok(`Removed LaunchAgent "${LABEL}".`);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
const r = runSafe("launchctl", ["start", LABEL]);
|
|
38
|
+
return r.ok ? ok("Started.") : fail(r.out);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async stop() {
|
|
42
|
+
const r = runSafe("launchctl", ["stop", LABEL]);
|
|
43
|
+
return r.ok ? ok("Stopped.") : fail(r.out);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async status() {
|
|
47
|
+
const r = runSafe("launchctl", ["list"]);
|
|
48
|
+
const line = r.out.split("\n").find((l) => l.includes(LABEL));
|
|
49
|
+
return ok(line ? `Loaded: ${line.trim()}` : "Not loaded.");
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function plist(spec: LaunchSpec): string {
|
|
54
|
+
const args = [spec.nodePath, ...spec.args].map((a) => ` <string>${esc(a)}</string>`).join("\n");
|
|
55
|
+
const envEntries = Object.entries(spec.env ?? {});
|
|
56
|
+
const envBlock = envEntries.length
|
|
57
|
+
? [
|
|
58
|
+
" <key>EnvironmentVariables</key>",
|
|
59
|
+
" <dict>",
|
|
60
|
+
...envEntries.flatMap(([k, v]) => [` <key>${esc(k)}</key>`, ` <string>${esc(v)}</string>`]),
|
|
61
|
+
" </dict>",
|
|
62
|
+
]
|
|
63
|
+
: [];
|
|
64
|
+
return [
|
|
65
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
66
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
67
|
+
'<plist version="1.0">',
|
|
68
|
+
"<dict>",
|
|
69
|
+
" <key>Label</key>",
|
|
70
|
+
` <string>${LABEL}</string>`,
|
|
71
|
+
" <key>ProgramArguments</key>",
|
|
72
|
+
" <array>",
|
|
73
|
+
args,
|
|
74
|
+
" </array>",
|
|
75
|
+
...envBlock,
|
|
76
|
+
" <key>WorkingDirectory</key>",
|
|
77
|
+
` <string>${esc(spec.cwd)}</string>`,
|
|
78
|
+
" <key>RunAtLoad</key>",
|
|
79
|
+
" <true/>",
|
|
80
|
+
" <key>KeepAlive</key>",
|
|
81
|
+
" <true/>",
|
|
82
|
+
" <key>StandardOutPath</key>",
|
|
83
|
+
` <string>${esc(spec.logFile)}</string>`,
|
|
84
|
+
" <key>StandardErrorPath</key>",
|
|
85
|
+
` <string>${esc(spec.logFile)}</string>`,
|
|
86
|
+
"</dict>",
|
|
87
|
+
"</plist>",
|
|
88
|
+
"",
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function esc(s: string): string {
|
|
93
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ok(message: string): ServiceResult {
|
|
97
|
+
return { ok: true, message };
|
|
98
|
+
}
|
|
99
|
+
function fail(message: string): ServiceResult {
|
|
100
|
+
return { ok: false, message };
|
|
101
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform detection, launch-spec construction, and a small command runner
|
|
3
|
+
* shared by the per-OS service controllers.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { PROJECT_ROOT, INSTANCE_DIR } from "../config.js";
|
|
8
|
+
|
|
9
|
+
export type Platform = "windows" | "linux" | "macos" | "unknown";
|
|
10
|
+
|
|
11
|
+
export function detectPlatform(): Platform {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case "win32":
|
|
14
|
+
return "windows";
|
|
15
|
+
case "linux":
|
|
16
|
+
return "linux";
|
|
17
|
+
case "darwin":
|
|
18
|
+
return "macos";
|
|
19
|
+
default:
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import type { LaunchSpec } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** Build the launch spec that runs the bot via the current node + tsx loader. */
|
|
27
|
+
export function buildLaunchSpec(): LaunchSpec {
|
|
28
|
+
const logsDir = join(INSTANCE_DIR, "logs");
|
|
29
|
+
const args = ["--import", "tsx", join(PROJECT_ROOT, "src", "index.ts")];
|
|
30
|
+
// Run the code from the package dir (cwd below) so `tsx` resolves, but tell
|
|
31
|
+
// the bot where its .env/logs/data live. Only appended for a global install
|
|
32
|
+
// (instance dir differs) so in-place checkouts keep identical launch args.
|
|
33
|
+
if (INSTANCE_DIR !== PROJECT_ROOT) args.push("--instance", INSTANCE_DIR);
|
|
34
|
+
return {
|
|
35
|
+
id: "grok-telegram-bot",
|
|
36
|
+
displayName: "Grok Telegram Bot",
|
|
37
|
+
nodePath: process.execPath,
|
|
38
|
+
args,
|
|
39
|
+
cwd: PROJECT_ROOT,
|
|
40
|
+
// Tells the running bot it's under a supervisor (systemd/launchd) that
|
|
41
|
+
// relaunches on exit — so its auto-updater exits cleanly instead of
|
|
42
|
+
// re-exec'ing (which would double-run). Windows applies no env, so its
|
|
43
|
+
// Scheduled Task (no auto-restart) takes the re-exec path instead.
|
|
44
|
+
env: { GROK_TG_SUPERVISED: "1" },
|
|
45
|
+
logsDir,
|
|
46
|
+
logFile: join(logsDir, "grok-telegram-bot.log"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run a command, returning combined output. Throws on non-zero exit. */
|
|
51
|
+
export function run(cmd: string, args: string[]): string {
|
|
52
|
+
return execFileSync(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run a command, swallowing errors and returning { ok, out }. */
|
|
56
|
+
export function runSafe(cmd: string, args: string[]): { ok: boolean; out: string } {
|
|
57
|
+
try {
|
|
58
|
+
return { ok: true, out: run(cmd, args) };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
const err = e as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string };
|
|
61
|
+
const out = String(err.stdout ?? "") + String(err.stderr ?? "") || err.message || "failed";
|
|
62
|
+
return { ok: false, out };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform service (daemon) abstraction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface LaunchSpec {
|
|
6
|
+
/** Internal service id, e.g. "grok-telegram-bot". */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Human-readable name. */
|
|
9
|
+
displayName: string;
|
|
10
|
+
/** Absolute path to the node binary that should run the bot. */
|
|
11
|
+
nodePath: string;
|
|
12
|
+
/** Arguments after the node binary (tsx loader + entry file). */
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Working directory (the installed bot folder). */
|
|
15
|
+
cwd: string;
|
|
16
|
+
/** Extra environment variables for the service process. */
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
/** Absolute log file path. */
|
|
19
|
+
logFile: string;
|
|
20
|
+
/** Log directory. */
|
|
21
|
+
logsDir: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ServiceResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ServiceController {
|
|
30
|
+
readonly platform: string;
|
|
31
|
+
install(spec: LaunchSpec): Promise<ServiceResult>;
|
|
32
|
+
uninstall(spec: LaunchSpec): Promise<ServiceResult>;
|
|
33
|
+
start(spec: LaunchSpec): Promise<ServiceResult>;
|
|
34
|
+
stop(spec: LaunchSpec): Promise<ServiceResult>;
|
|
35
|
+
status(spec: LaunchSpec): Promise<ServiceResult>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows service controller — runs the bot at logon. Preferred mechanism is a
|
|
3
|
+
* hidden ONLOGON Scheduled Task, but registering a logon-triggered task needs
|
|
4
|
+
* admin, so from a normal (non-elevated) terminal we fall back to a launcher in
|
|
5
|
+
* the per-user Startup folder — both run a small .vbs that starts node with no
|
|
6
|
+
* console window; the app logs to a file. Stop precisely targets our node
|
|
7
|
+
* process by command line, so it works regardless of how it was launched.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { runSafe } from "./platform.js";
|
|
12
|
+
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const TASK = "GrokTelegramBot";
|
|
15
|
+
/** Launcher dropped in the per-user Startup folder when no admin is available. */
|
|
16
|
+
const STARTUP_VBS = "GrokTelegramBot.vbs";
|
|
17
|
+
|
|
18
|
+
/** The per-user Startup folder (runs at logon for the current user, no admin).
|
|
19
|
+
* Undefined only if APPDATA is unset (e.g. running with no roaming profile). */
|
|
20
|
+
function startupDir(): string | undefined {
|
|
21
|
+
const appData = process.env.APPDATA;
|
|
22
|
+
return appData ? join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup") : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function startupVbsPath(): string | undefined {
|
|
26
|
+
const dir = startupDir();
|
|
27
|
+
return dir ? join(dir, STARTUP_VBS) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove a leftover Startup-folder launcher (e.g. from an earlier non-elevated
|
|
31
|
+
* install) so a task-based install never double-launches the bot at logon. */
|
|
32
|
+
function removeStartupLauncher(): void {
|
|
33
|
+
const p = startupVbsPath();
|
|
34
|
+
if (p) rmSync(p, { force: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Canonical launcher in the bot folder (the Scheduled Task points at it). */
|
|
38
|
+
function vbsPath(spec: LaunchSpec): string {
|
|
39
|
+
return join(spec.cwd, "run-service.vbs");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when our hidden Scheduled Task is registered. */
|
|
43
|
+
function taskInstalled(): boolean {
|
|
44
|
+
return runSafe("schtasks", ["/Query", "/TN", TASK]).ok;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a bot process matching this spec is currently running. Launch
|
|
48
|
+
* paths use this to avoid starting a second instance — two pollers on one
|
|
49
|
+
* bot token make Telegram return 409 Conflict. */
|
|
50
|
+
function isRunning(spec: LaunchSpec): boolean {
|
|
51
|
+
const proc = runSafe("powershell", ["-NoProfile", "-Command", countScript(entryOf(spec))]);
|
|
52
|
+
return proc.ok && /[1-9]\d*/.test(proc.out.trim());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const windowsController: ServiceController = {
|
|
56
|
+
platform: "windows",
|
|
57
|
+
|
|
58
|
+
async install(spec) {
|
|
59
|
+
mkdirSync(spec.logsDir, { recursive: true });
|
|
60
|
+
const vbs = vbsPath(spec);
|
|
61
|
+
writeFileSync(vbs, vbsLauncher(spec), "utf-8");
|
|
62
|
+
|
|
63
|
+
// Preferred: a hidden ONLOGON Scheduled Task. Registering a *logon-triggered*
|
|
64
|
+
// task is a privileged operation, so /Create succeeds only from an elevated
|
|
65
|
+
// (admin) terminal. From a normal terminal it returns "Access is denied".
|
|
66
|
+
runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // replace if present
|
|
67
|
+
const res = runSafe("schtasks", [
|
|
68
|
+
"/Create",
|
|
69
|
+
"/F",
|
|
70
|
+
"/SC",
|
|
71
|
+
"ONLOGON",
|
|
72
|
+
"/TN",
|
|
73
|
+
TASK,
|
|
74
|
+
"/TR",
|
|
75
|
+
`wscript.exe "${vbs}"`,
|
|
76
|
+
]);
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
removeStartupLauncher(); // avoid a leftover launcher double-starting the bot
|
|
79
|
+
if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
80
|
+
return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A task may still exist that we just couldn't overwrite (e.g. created by an
|
|
84
|
+
// earlier elevated install). Reuse it rather than ALSO adding a Startup
|
|
85
|
+
// launcher, which would double-launch the bot at logon (409 Conflict).
|
|
86
|
+
if (taskInstalled()) {
|
|
87
|
+
removeStartupLauncher();
|
|
88
|
+
if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
89
|
+
return ok(`Scheduled task "${TASK}" already exists; launched it. (Re-run elevated to recreate it.)`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fallback (no admin — the common case): drop the launcher in the per-user
|
|
93
|
+
// Startup folder. It runs hidden at every logon with no elevation.
|
|
94
|
+
const startupVbs = startupVbsPath();
|
|
95
|
+
const dir = startupDir();
|
|
96
|
+
if (!startupVbs || !dir) {
|
|
97
|
+
return fail(
|
|
98
|
+
`Could not create the logon task (${res.out.trim()}) and no per-user Startup folder is available. ` +
|
|
99
|
+
`Re-run "grok-tg install" from an elevated terminal (Run as administrator).`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
writeFileSync(startupVbs, vbsLauncher(spec), "utf-8");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return fail(`Startup-folder install failed: ${(e as Error).message}`);
|
|
107
|
+
}
|
|
108
|
+
if (!isRunning(spec)) runSafe("wscript.exe", [startupVbs]); // launch now
|
|
109
|
+
return ok(
|
|
110
|
+
`Installed via the Startup folder — starts hidden at logon, no admin needed — and launched it.\n` +
|
|
111
|
+
`(Tip: run "grok-tg install" from an elevated terminal to use a hidden Scheduled Task instead.)`,
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async uninstall(spec) {
|
|
116
|
+
await this.stop(spec);
|
|
117
|
+
runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // best-effort (may not exist)
|
|
118
|
+
rmSync(vbsPath(spec), { force: true });
|
|
119
|
+
const startupVbs = startupVbsPath();
|
|
120
|
+
if (startupVbs) rmSync(startupVbs, { force: true });
|
|
121
|
+
return ok(`Removed "${TASK}" (scheduled task and/or Startup launcher).`);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async start(spec) {
|
|
125
|
+
if (isRunning(spec)) return ok("Already running.");
|
|
126
|
+
if (taskInstalled()) {
|
|
127
|
+
const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
128
|
+
return res.ok ? ok("Started.") : fail(res.out);
|
|
129
|
+
}
|
|
130
|
+
const startupVbs = startupVbsPath();
|
|
131
|
+
if (startupVbs && existsSync(startupVbs)) {
|
|
132
|
+
runSafe("wscript.exe", [startupVbs]);
|
|
133
|
+
return ok("Started.");
|
|
134
|
+
}
|
|
135
|
+
return fail(`Not installed. Run "grok-tg install" first.`);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async stop(spec) {
|
|
139
|
+
runSafe("schtasks", ["/End", "/TN", TASK]); // best-effort if task-based
|
|
140
|
+
const res = runSafe("powershell", ["-NoProfile", "-Command", killScript(entryOf(spec))]);
|
|
141
|
+
return ok(`Stopped. ${res.out.trim()}`);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async status(spec) {
|
|
145
|
+
const installedTask = taskInstalled();
|
|
146
|
+
const startupVbs = startupVbsPath();
|
|
147
|
+
const installedStartup = !!startupVbs && existsSync(startupVbs);
|
|
148
|
+
const installed = installedTask || installedStartup;
|
|
149
|
+
const running = isRunning(spec);
|
|
150
|
+
const how = installedTask ? "scheduled task" : installedStartup ? "Startup folder" : "—";
|
|
151
|
+
const detail = installedTask
|
|
152
|
+
? `\n${runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]).out.trim()}`
|
|
153
|
+
: installedStartup
|
|
154
|
+
? `\nLauncher: ${startupVbs}`
|
|
155
|
+
: "";
|
|
156
|
+
return ok(
|
|
157
|
+
`Installed: ${installed ? `yes (${how})` : "no"} | Running: ${running ? "yes" : "no"}${detail}`,
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** The bot entry file — unique enough to identify the bot process. It may be
|
|
163
|
+
* followed by trailing args (e.g. `--instance <dir>`), so find it explicitly. */
|
|
164
|
+
function entryOf(spec: LaunchSpec): string {
|
|
165
|
+
return (
|
|
166
|
+
spec.args.find((a) => a.endsWith("index.ts")) ?? spec.args[spec.args.length - 1] ?? spec.cwd
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function vbsLauncher(spec: LaunchSpec): string {
|
|
171
|
+
const cmd = `""${spec.nodePath}"" ${spec.args.map((a) => `""${a}""`).join(" ")}`;
|
|
172
|
+
return [
|
|
173
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
174
|
+
`sh.CurrentDirectory = "${spec.cwd}"`,
|
|
175
|
+
`sh.Run "${cmd}", 0, False`,
|
|
176
|
+
].join("\r\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function killScript(entry: string): string {
|
|
180
|
+
const safe = entry.replace(/'/g, "''");
|
|
181
|
+
return [
|
|
182
|
+
`$p = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' };`,
|
|
183
|
+
`$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
|
|
184
|
+
`"killed " + (@($p).Count)`,
|
|
185
|
+
].join(" ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function countScript(entry: string): string {
|
|
189
|
+
const safe = entry.replace(/'/g, "''");
|
|
190
|
+
return `@(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' }).Count`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ok(message: string): ServiceResult {
|
|
194
|
+
return { ok: true, message };
|
|
195
|
+
}
|
|
196
|
+
function fail(message: string): ServiceResult {
|
|
197
|
+
return { ok: false, message };
|
|
198
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History parser — turns a session's .jsonl event log into readable entries.
|
|
3
|
+
* Reads only the tail of large logs to stay fast.
|
|
4
|
+
*/
|
|
5
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
6
|
+
import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
7
|
+
import type { HistoryEntry, HistoryRole } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
|
|
10
|
+
|
|
11
|
+
interface RawEvent {
|
|
12
|
+
kind?: string;
|
|
13
|
+
data?: {
|
|
14
|
+
content?: Array<{ kind?: string; data?: unknown; text?: unknown }>;
|
|
15
|
+
meta?: { timestamp?: number };
|
|
16
|
+
name?: string;
|
|
17
|
+
tool_name?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse the most recent `maxEntries` history entries from a session log. */
|
|
22
|
+
export function readHistory(jsonlPath: string, maxEntries = 20): HistoryEntry[] {
|
|
23
|
+
for (const window of TAIL_WINDOWS) {
|
|
24
|
+
const entries = parseTail(jsonlPath, window, maxEntries);
|
|
25
|
+
if (entries.length > 0) return entries;
|
|
26
|
+
}
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Current byte size of a session log (0 if missing). */
|
|
31
|
+
export function jsonlSize(jsonlPath: string): number {
|
|
32
|
+
try {
|
|
33
|
+
return statSync(jsonlPath).size;
|
|
34
|
+
} catch {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Last-write time of a session log in epoch ms (0 if missing). */
|
|
40
|
+
export function jsonlMtimeMs(jsonlPath: string): number {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(jsonlPath).mtimeMs;
|
|
43
|
+
} catch {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The first user prompt in a session log (read from the start), or "". */
|
|
49
|
+
export function readFirstPrompt(jsonlPath: string, maxBytes = 256 * 1024): string {
|
|
50
|
+
let size: number;
|
|
51
|
+
try {
|
|
52
|
+
size = statSync(jsonlPath).size;
|
|
53
|
+
} catch {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
if (size === 0) return "";
|
|
57
|
+
const length = Math.min(size, maxBytes);
|
|
58
|
+
const fd = openSync(jsonlPath, "r");
|
|
59
|
+
try {
|
|
60
|
+
const buf = Buffer.alloc(length);
|
|
61
|
+
readSync(fd, buf, 0, length, 0);
|
|
62
|
+
for (const line of buf.toString("utf-8").split("\n")) {
|
|
63
|
+
const e = parseEventLine(line);
|
|
64
|
+
if (e && e.role === "user" && e.text.trim()) return e.text;
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the entries appended after `fromByte` (the "unread" since last seen).
|
|
74
|
+
* Returns the parsed entries and the new end-of-file byte offset. Grok appends
|
|
75
|
+
* whole newline-terminated JSON objects, so `fromByte` is always a line boundary.
|
|
76
|
+
*/
|
|
77
|
+
export function readEntriesFrom(jsonlPath: string, fromByte: number): { entries: HistoryEntry[]; size: number } {
|
|
78
|
+
const size = jsonlSize(jsonlPath);
|
|
79
|
+
if (size <= fromByte || size === 0) return { entries: [], size };
|
|
80
|
+
const length = size - fromByte;
|
|
81
|
+
const fd = openSync(jsonlPath, "r");
|
|
82
|
+
try {
|
|
83
|
+
const buf = Buffer.alloc(length);
|
|
84
|
+
readSync(fd, buf, 0, length, fromByte);
|
|
85
|
+
const lines = buf.toString("utf-8").split("\n").filter((l) => l.trim().length > 0);
|
|
86
|
+
const entries: HistoryEntry[] = [];
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const e = parseEventLine(line);
|
|
89
|
+
if (e) entries.push(e);
|
|
90
|
+
}
|
|
91
|
+
return { entries, size };
|
|
92
|
+
} finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseTail(jsonlPath: string, window: number, maxEntries: number): HistoryEntry[] {
|
|
98
|
+
const text = readTail(jsonlPath, window);
|
|
99
|
+
if (!text) return [];
|
|
100
|
+
|
|
101
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
102
|
+
const entries: HistoryEntry[] = [];
|
|
103
|
+
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const entry = parseEventLine(line);
|
|
106
|
+
if (entry) entries.push(entry);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return entries.slice(-maxEntries);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a single .jsonl event line into a history entry (or undefined). */
|
|
113
|
+
export function parseEventLine(line: string): HistoryEntry | undefined {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) return undefined;
|
|
116
|
+
let ev: RawEvent;
|
|
117
|
+
try {
|
|
118
|
+
ev = JSON.parse(trimmed) as RawEvent;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return toEntry(ev);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build a compact plain-text transcript from history entries (for priming). */
|
|
126
|
+
export function buildTranscript(entries: HistoryEntry[], perEntryMax = 600): string {
|
|
127
|
+
const label: Record<string, string> = {
|
|
128
|
+
user: "User",
|
|
129
|
+
assistant: "Assistant",
|
|
130
|
+
tool: "Tool",
|
|
131
|
+
system: "System",
|
|
132
|
+
};
|
|
133
|
+
return entries
|
|
134
|
+
.map((e) => {
|
|
135
|
+
const text = e.text.length > perEntryMax ? e.text.slice(0, perEntryMax) + " …" : e.text;
|
|
136
|
+
return `${label[e.role] ?? e.role}: ${text}`;
|
|
137
|
+
})
|
|
138
|
+
.join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toEntry(ev: RawEvent): HistoryEntry | undefined {
|
|
142
|
+
const role = roleOf(ev.kind);
|
|
143
|
+
if (!role) return undefined;
|
|
144
|
+
|
|
145
|
+
const text = cleanStoredText(extractText(ev.data?.content));
|
|
146
|
+
const tool = ev.data?.tool_name || ev.data?.name;
|
|
147
|
+
if (!text && !tool) return undefined;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
role,
|
|
151
|
+
text: text || (tool ? `(${tool})` : ""),
|
|
152
|
+
tool,
|
|
153
|
+
timestamp: ev.data?.meta?.timestamp,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Strip the `{progress: N%}` markers (any role) and the appended progress
|
|
158
|
+
* directive (user prompts) from persisted text so history / unread / previews
|
|
159
|
+
* / fork-priming never surface the raw plumbing. */
|
|
160
|
+
function cleanStoredText(text: string): string {
|
|
161
|
+
if (!text) return text;
|
|
162
|
+
let t = extractProgress(text).cleaned;
|
|
163
|
+
if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
|
|
164
|
+
return t;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function roleOf(kind?: string): HistoryRole | undefined {
|
|
168
|
+
switch (kind) {
|
|
169
|
+
case "Prompt":
|
|
170
|
+
case "UserMessage":
|
|
171
|
+
return "user";
|
|
172
|
+
case "AssistantMessage":
|
|
173
|
+
case "Response":
|
|
174
|
+
return "assistant";
|
|
175
|
+
case "ToolUse":
|
|
176
|
+
case "ToolUseResults":
|
|
177
|
+
return "tool";
|
|
178
|
+
default:
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractText(content?: Array<{ kind?: string; data?: unknown; text?: unknown }>): string {
|
|
184
|
+
if (!Array.isArray(content)) return "";
|
|
185
|
+
const parts: string[] = [];
|
|
186
|
+
for (const block of content) {
|
|
187
|
+
if (block.kind === "text") {
|
|
188
|
+
if (typeof block.data === "string") parts.push(block.data);
|
|
189
|
+
else if (block.data && typeof (block.data as { text?: unknown }).text === "string") {
|
|
190
|
+
parts.push((block.data as { text: string }).text);
|
|
191
|
+
}
|
|
192
|
+
} else if (typeof block.text === "string") {
|
|
193
|
+
parts.push(block.text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return parts.join("").trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Read up to `maxBytes` from the end of a file as UTF-8 text. */
|
|
200
|
+
function readTail(path: string, maxBytes: number): string {
|
|
201
|
+
let size: number;
|
|
202
|
+
try {
|
|
203
|
+
size = statSync(path).size;
|
|
204
|
+
} catch {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
if (size === 0) return "";
|
|
208
|
+
|
|
209
|
+
const start = Math.max(0, size - maxBytes);
|
|
210
|
+
const length = size - start;
|
|
211
|
+
const fd = openSync(path, "r");
|
|
212
|
+
try {
|
|
213
|
+
const buf = Buffer.alloc(length);
|
|
214
|
+
readSync(fd, buf, 0, length, start);
|
|
215
|
+
let text = buf.toString("utf-8");
|
|
216
|
+
// If we started mid-file, drop the partial first line.
|
|
217
|
+
if (start > 0) {
|
|
218
|
+
const nl = text.indexOf("\n");
|
|
219
|
+
if (nl !== -1) text = text.slice(nl + 1);
|
|
220
|
+
}
|
|
221
|
+
return text;
|
|
222
|
+
} finally {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process control helpers for Grok sessions: force-killing the process that
|
|
3
|
+
* holds a session's `.lock` (its `lockPid`). Shared by /killall and the
|
|
4
|
+
* per-session kill button so the behaviour stays identical.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
const log = createLogger("sessions:process");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Force-kill a process by PID — and its child tree on Windows (`taskkill /T`),
|
|
13
|
+
* which a `grok` session may have spawned (shells, tools). Returns whether
|
|
14
|
+
* the kill command was issued without throwing. A non-existent PID or one we
|
|
15
|
+
* may not signal counts as failure (callers report it).
|
|
16
|
+
*/
|
|
17
|
+
export function killPid(pid: number): boolean {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
|
22
|
+
} else {
|
|
23
|
+
process.kill(pid, "SIGKILL");
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
log.debug(`kill ${pid} failed:`, (e as Error).message);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|