grok-telegram-bot 2.6.0 → 2.7.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 +13 -0
- package/CHANGELOG.md +35 -0
- package/README.md +15 -2
- package/docs/INSTALL.md +2 -0
- package/package.json +1 -1
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +7 -0
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/bot.ts +32 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/session-runtime.ts +2 -0
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/service/linux.ts +21 -15
- package/src/service/macos.ts +20 -15
- package/src/service/platform.ts +12 -3
- package/src/service/types.ts +6 -0
- package/src/service/windows.ts +31 -22
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive plan-mode approval. Grok's exit_plan_mode reverse request
|
|
3
|
+
* becomes Approve / Request changes / Abandon buttons (same idea as
|
|
4
|
+
* PermissionService). Auto-approve is used when configured, or when the
|
|
5
|
+
* session has no owning chat (scheduled / orphan).
|
|
6
|
+
*/
|
|
7
|
+
import type { Api } from "grammy";
|
|
8
|
+
import { InlineKeyboard } from "grammy";
|
|
9
|
+
import type { PlanExitDecision, PlanExitOutcome } from "../grok/plan-approval.js";
|
|
10
|
+
import { outboundThreadExtra } from "../forum/thread.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("plan-exit");
|
|
15
|
+
const TIMEOUT_MS = 30 * 60 * 1000;
|
|
16
|
+
const PREVIEW = 900;
|
|
17
|
+
|
|
18
|
+
interface Pending {
|
|
19
|
+
resolve: (d: PlanExitDecision) => void;
|
|
20
|
+
chatId: number;
|
|
21
|
+
messageId?: number;
|
|
22
|
+
timer: NodeJS.Timeout;
|
|
23
|
+
pinned: boolean;
|
|
24
|
+
waitingFeedback: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PlanExitService {
|
|
28
|
+
private readonly pending = new Map<string, Pending>();
|
|
29
|
+
/** chatId → reqId while we wait for revision notes after "Request changes". */
|
|
30
|
+
private readonly feedbackFor = new Map<number, string>();
|
|
31
|
+
private seq = 0;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly api: Api,
|
|
35
|
+
private readonly registry: RuntimeRegistry,
|
|
36
|
+
public autoApprove = false,
|
|
37
|
+
private readonly onUnpinned?: (chatId: number) => void | Promise<void>,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
async handle(params: Record<string, unknown>): Promise<PlanExitDecision> {
|
|
41
|
+
const sessionId = str(params.sessionId) || str(params.session_id) || "";
|
|
42
|
+
const planText = extractPlanText(params);
|
|
43
|
+
if (this.autoApprove) {
|
|
44
|
+
log.info(`auto-approved plan exit for ${sessionId.slice(0, 8) || "?"}`);
|
|
45
|
+
return { outcome: "approved", feedback: "" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
|
|
49
|
+
const chatId = desc.chatId;
|
|
50
|
+
if (chatId === undefined) return { outcome: "approved", feedback: "" };
|
|
51
|
+
const threadExtra = outboundThreadExtra(desc.threadId);
|
|
52
|
+
|
|
53
|
+
const reqId = String(++this.seq);
|
|
54
|
+
const preview = planText.replace(/\s+/g, " ").trim().slice(0, PREVIEW);
|
|
55
|
+
const body = [
|
|
56
|
+
"\u{1F4CB} Plan ready \u2014 review before Grok implements.",
|
|
57
|
+
preview ? `\n${preview}${planText.length > PREVIEW ? "\u2026" : ""}` : "\n(No plan text in the request.)",
|
|
58
|
+
"\nApprove to build, request changes (then send notes), or abandon.",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
const kb = new InlineKeyboard()
|
|
62
|
+
.text("\u2705 Approve", `planx:${reqId}:ok`)
|
|
63
|
+
.text("\u270F\uFE0F Changes", `planx:${reqId}:chg`)
|
|
64
|
+
.row()
|
|
65
|
+
.text("\u26D4 Abandon", `planx:${reqId}:no`);
|
|
66
|
+
|
|
67
|
+
let messageId: number | undefined;
|
|
68
|
+
let pinned = false;
|
|
69
|
+
try {
|
|
70
|
+
const msg = await this.api.sendMessage(chatId, body, {
|
|
71
|
+
reply_markup: kb,
|
|
72
|
+
disable_notification: false,
|
|
73
|
+
...threadExtra,
|
|
74
|
+
});
|
|
75
|
+
messageId = msg.message_id;
|
|
76
|
+
try {
|
|
77
|
+
await this.api.pinChatMessage(chatId, messageId, { disable_notification: true });
|
|
78
|
+
pinned = true;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
log.warn("pin plan prompt failed:", (e as Error).message);
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log.warn("send plan prompt failed:", (e as Error).message);
|
|
84
|
+
return { outcome: "approved", feedback: "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Promise<PlanExitDecision>((resolve) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
const p = this.pending.get(reqId);
|
|
90
|
+
if (!p) return;
|
|
91
|
+
this.pending.delete(reqId);
|
|
92
|
+
this.feedbackFor.delete(p.chatId);
|
|
93
|
+
void this.finish(p, "\u231B Plan approval timed out \u2014 abandoned.");
|
|
94
|
+
resolve({ outcome: "abandoned", feedback: "timed out waiting for review" });
|
|
95
|
+
}, TIMEOUT_MS);
|
|
96
|
+
this.pending.set(reqId, { resolve, chatId, messageId, timer, pinned, waitingFeedback: false });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Button tap. Returns a short toast; "chg" waits for the next text message. */
|
|
101
|
+
resolveChoice(reqId: string, action: string): string | undefined {
|
|
102
|
+
const p = this.pending.get(reqId);
|
|
103
|
+
if (!p) return undefined;
|
|
104
|
+
if (action === "chg") {
|
|
105
|
+
p.waitingFeedback = true;
|
|
106
|
+
this.feedbackFor.set(p.chatId, reqId);
|
|
107
|
+
void this.api
|
|
108
|
+
.editMessageText(p.chatId, p.messageId ?? 0, "\u270F\uFE0F Send revision notes as your next message.", {
|
|
109
|
+
reply_markup: { inline_keyboard: [] },
|
|
110
|
+
})
|
|
111
|
+
.catch(() => {});
|
|
112
|
+
return "Send change notes";
|
|
113
|
+
}
|
|
114
|
+
clearTimeout(p.timer);
|
|
115
|
+
this.pending.delete(reqId);
|
|
116
|
+
this.feedbackFor.delete(p.chatId);
|
|
117
|
+
const outcome: PlanExitOutcome = action === "ok" ? "approved" : "abandoned";
|
|
118
|
+
const label = outcome === "approved" ? "\u2705 Plan approved \u2014 implementing." : "\u26D4 Plan abandoned.";
|
|
119
|
+
void this.finish(p, label);
|
|
120
|
+
p.resolve({ outcome, feedback: "" });
|
|
121
|
+
return outcome === "approved" ? "Approved" : "Abandoned";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** If this chat is waiting for revision notes, consume the text. */
|
|
125
|
+
takeFeedback(chatId: number, text: string): boolean {
|
|
126
|
+
const reqId = this.feedbackFor.get(chatId);
|
|
127
|
+
if (!reqId) return false;
|
|
128
|
+
const p = this.pending.get(reqId);
|
|
129
|
+
this.feedbackFor.delete(chatId);
|
|
130
|
+
if (!p) return false;
|
|
131
|
+
clearTimeout(p.timer);
|
|
132
|
+
this.pending.delete(reqId);
|
|
133
|
+
const notes = text.trim().slice(0, 4000);
|
|
134
|
+
void this.finish(p, `\u270F\uFE0F Requested changes:\n${notes.slice(0, 400)}`);
|
|
135
|
+
p.resolve({ outcome: "request_changes", feedback: notes || "Please revise the plan." });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async finish(p: Pending, text: string): Promise<void> {
|
|
140
|
+
if (p.messageId !== undefined) {
|
|
141
|
+
await this.api
|
|
142
|
+
.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } })
|
|
143
|
+
.catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
if (p.pinned && p.messageId !== undefined) {
|
|
146
|
+
p.pinned = false;
|
|
147
|
+
await this.api.unpinChatMessage(p.chatId, p.messageId).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
if (this.onUnpinned) {
|
|
150
|
+
try {
|
|
151
|
+
await this.onUnpinned(p.chatId);
|
|
152
|
+
} catch {
|
|
153
|
+
/* non-fatal */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function extractPlanText(params: Record<string, unknown>): string {
|
|
160
|
+
for (const k of ["plan_content", "planContent", "content", "plan", "text"]) {
|
|
161
|
+
const v = params[k];
|
|
162
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
163
|
+
}
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function str(v: unknown): string {
|
|
168
|
+
return typeof v === "string" ? v : "";
|
|
169
|
+
}
|
package/src/bot/registry.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { SessionRuntime } from "./session-runtime.js";
|
|
|
24
24
|
export interface SessionDescription {
|
|
25
25
|
/** Chat that owns the session (controlled session or subagent parent). */
|
|
26
26
|
chatId?: number;
|
|
27
|
+
/** Forum topic thread id when the session lives in a project topic. */
|
|
28
|
+
threadId?: number;
|
|
27
29
|
/** True when this is a session the chat directly controls. */
|
|
28
30
|
controlled: boolean;
|
|
29
31
|
/** True when this is a subagent of a controlled turn. */
|
|
@@ -202,10 +204,17 @@ export class RuntimeRegistry {
|
|
|
202
204
|
describeSession(sessionId: string): SessionDescription {
|
|
203
205
|
const controlledChat = this.findChatBySession(sessionId);
|
|
204
206
|
if (controlledChat !== undefined) {
|
|
205
|
-
const
|
|
207
|
+
const forum = this.forumControllerForSession(sessionId);
|
|
208
|
+
const project = (forum ?? this.controller(controlledChat))
|
|
206
209
|
.list()
|
|
207
210
|
.find((s) => s.sessionId === sessionId)?.projectName;
|
|
208
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
chatId: controlledChat,
|
|
213
|
+
threadId: forum?.messageThreadId,
|
|
214
|
+
controlled: true,
|
|
215
|
+
subagent: false,
|
|
216
|
+
projectName: project,
|
|
217
|
+
};
|
|
209
218
|
}
|
|
210
219
|
const parent = this.subagentParents.get(sessionId);
|
|
211
220
|
const info = this.acp.subagentById(sessionId);
|
|
@@ -846,6 +846,7 @@ export class SessionRuntime {
|
|
|
846
846
|
// Never wrap meta follow-ups even if somehow first.
|
|
847
847
|
if (
|
|
848
848
|
input.skipSelfRecheck ||
|
|
849
|
+
input.rawSlashCommand ||
|
|
849
850
|
isSelfRecheckPrompt(input.text) ||
|
|
850
851
|
isTelegramBridgeResultsPrompt(input.text) ||
|
|
851
852
|
isManagerWorkReportPrompt(input.text)
|
|
@@ -870,6 +871,7 @@ export class SessionRuntime {
|
|
|
870
871
|
private applyManagerContext(input: PromptInput): PromptInput {
|
|
871
872
|
if (!this.managerMode) return input;
|
|
872
873
|
if (
|
|
874
|
+
input.rawSlashCommand ||
|
|
873
875
|
isTelegramBridgeResultsPrompt(input.text) ||
|
|
874
876
|
isManagerWorkReportPrompt(input.text) ||
|
|
875
877
|
isSelfRecheckPrompt(input.text)
|
package/src/cli.ts
CHANGED
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { listKnownInstances, stripInstanceFlags } from "./app/instance.js";
|
|
13
14
|
import { ENV_PATH, INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
|
|
14
15
|
import { buildLaunchSpec, getController } from "./service/index.js";
|
|
15
16
|
|
|
16
17
|
const HELP = `Grok Telegram Bot — CLI
|
|
17
18
|
|
|
18
|
-
Usage: grok-tg <command>
|
|
19
|
+
Usage: grok-tg [--name <slug>] [--instance <dir>] <command>
|
|
19
20
|
|
|
20
21
|
run Run in the foreground
|
|
21
|
-
setup [--path] Create/update .env (default ~/.grok/tg/.env
|
|
22
|
-
|
|
22
|
+
setup [--path] Create/update .env (default ~/.grok/tg/.env);
|
|
23
|
+
--name <slug> writes ~/.grok/tg/instances/<slug>/.env
|
|
23
24
|
install Install + start a background service (autostart on boot)
|
|
24
25
|
uninstall Stop + remove the background service
|
|
25
26
|
start Start the service
|
|
@@ -27,12 +28,20 @@ Usage: grok-tg <command>
|
|
|
27
28
|
restart Restart the service
|
|
28
29
|
status Show install + running status
|
|
29
30
|
logs [n] Show the last n log lines (default 100)
|
|
31
|
+
instances List named bot instances on this host
|
|
30
32
|
help Show this help
|
|
33
|
+
|
|
34
|
+
Several Telegram bots on one host (one chat per project):
|
|
35
|
+
|
|
36
|
+
grok-tg --name work setup <BOT_TOKEN> <YOUR_USER_ID>
|
|
37
|
+
grok-tg --name work install
|
|
38
|
+
grok-tg --name work status
|
|
31
39
|
`;
|
|
32
40
|
|
|
33
41
|
async function main(): Promise<void> {
|
|
34
42
|
const args = process.argv.slice(2);
|
|
35
|
-
const
|
|
43
|
+
const rest = stripInstanceFlags(args);
|
|
44
|
+
const [cmd, arg] = rest;
|
|
36
45
|
|
|
37
46
|
switch (cmd) {
|
|
38
47
|
case "run":
|
|
@@ -45,9 +54,9 @@ async function main(): Promise<void> {
|
|
|
45
54
|
// Run the plain-node setup script, targeting this folder (.env lives in
|
|
46
55
|
// the instance dir). Pass through optional <token> [userId] args.
|
|
47
56
|
const script = join(PROJECT_ROOT, "scripts", "setup.mjs");
|
|
48
|
-
const r = spawnSync(process.execPath, [script, ...
|
|
57
|
+
const r = spawnSync(process.execPath, [script, "--instance", INSTANCE_DIR, ...rest.slice(1)], {
|
|
49
58
|
stdio: "inherit",
|
|
50
|
-
env: { ...process.env, GROK_TG_CWD: INSTANCE_DIR },
|
|
59
|
+
env: { ...process.env, GROK_TG_DIR: INSTANCE_DIR, GROK_TG_CWD: INSTANCE_DIR },
|
|
51
60
|
});
|
|
52
61
|
process.exit(r.status ?? 0);
|
|
53
62
|
break;
|
|
@@ -57,7 +66,11 @@ async function main(): Promise<void> {
|
|
|
57
66
|
preflight();
|
|
58
67
|
const r = await getController().install(buildLaunchSpec());
|
|
59
68
|
console.log(r.ok ? `✓ ${r.message}` : `✗ ${r.message}`);
|
|
60
|
-
if (r.ok)
|
|
69
|
+
if (r.ok) {
|
|
70
|
+
const spec = buildLaunchSpec();
|
|
71
|
+
const flag = spec.slug ? `--name ${spec.slug} ` : "";
|
|
72
|
+
console.log(`\nManage it with: grok-tg ${flag}status | stop | restart | logs`);
|
|
73
|
+
}
|
|
61
74
|
process.exit(r.ok ? 0 : 1);
|
|
62
75
|
break;
|
|
63
76
|
}
|
|
@@ -86,6 +99,11 @@ async function main(): Promise<void> {
|
|
|
86
99
|
process.exit(0);
|
|
87
100
|
break;
|
|
88
101
|
|
|
102
|
+
case "instances":
|
|
103
|
+
printInstances();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
break;
|
|
106
|
+
|
|
89
107
|
case "help":
|
|
90
108
|
case "--help":
|
|
91
109
|
case "-h":
|
|
@@ -112,6 +130,24 @@ function preflight(): void {
|
|
|
112
130
|
}
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
function printInstances(): void {
|
|
134
|
+
const items = listKnownInstances();
|
|
135
|
+
if (items.length === 0) {
|
|
136
|
+
console.log("No instances found.");
|
|
137
|
+
console.log(" grok-tg setup # default bot (~/.grok/tg)");
|
|
138
|
+
console.log(" grok-tg --name work setup <token> <userId> # second bot");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
console.log("Instances:\n");
|
|
142
|
+
for (const it of items) {
|
|
143
|
+
const manage = it.slug ? `grok-tg --name ${it.slug}` : "grok-tg";
|
|
144
|
+
console.log(` ${it.name}`);
|
|
145
|
+
console.log(` dir: ${it.dir}`);
|
|
146
|
+
console.log(` service: ${it.identity.id}`);
|
|
147
|
+
console.log(` manage: ${manage} status | restart | logs\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
115
151
|
function printLogs(n: number): void {
|
|
116
152
|
const file = buildLaunchSpec().logFile;
|
|
117
153
|
if (!existsSync(file)) {
|
package/src/config.ts
CHANGED
|
@@ -10,51 +10,42 @@ import { existsSync } from "node:fs";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import {
|
|
14
|
+
CANONICAL_DIR,
|
|
15
|
+
expandHome,
|
|
16
|
+
resolveInstanceDir as resolveNamedInstanceDir,
|
|
17
|
+
} from "./app/instance.js";
|
|
18
|
+
|
|
19
|
+
export { CANONICAL_DIR, expandHome };
|
|
13
20
|
|
|
14
21
|
/** Absolute path to the installed bot code (one level above src/). For a global
|
|
15
22
|
* npm install this lives inside node_modules — code lives here, never user data. */
|
|
16
23
|
export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
24
|
|
|
18
|
-
/** Canonical, path-independent home for this bot's `.env`, `logs/`, `data/` and
|
|
19
|
-
* the single-instance locks: `~/.grok/tg`. Used whenever the bot is started
|
|
20
|
-
* without an explicit instance dir and there's no `.env` in the current folder,
|
|
21
|
-
* so the SAME configuration is found no matter which directory you launch from. */
|
|
22
|
-
export const CANONICAL_DIR = join(homedir(), ".grok", "tg");
|
|
23
|
-
|
|
24
25
|
/**
|
|
25
26
|
* Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
|
|
26
27
|
* (first match wins):
|
|
27
28
|
* 1. `--instance <dir>` argv — set by the installed background service,
|
|
28
|
-
* 2. `
|
|
29
|
-
* 3. `
|
|
30
|
-
* 4.
|
|
29
|
+
* 2. `--name` / `GROK_TG_NAME` — named instance under `~/.grok/tg/instances/`,
|
|
30
|
+
* 3. `GROK_TG_DIR` env — an explicit override,
|
|
31
|
+
* 4. `GROK_TG_CWD` or cwd IF that folder already contains a `.env`,
|
|
31
32
|
* 5. the canonical `~/.grok/tg` home — the path-independent default.
|
|
32
33
|
*/
|
|
33
|
-
export const INSTANCE_DIR =
|
|
34
|
+
export const INSTANCE_DIR = resolveNamedInstanceDir({
|
|
35
|
+
argv: process.argv,
|
|
36
|
+
envDir: process.env.GROK_TG_DIR,
|
|
37
|
+
nameEnv: process.env.GROK_TG_NAME,
|
|
38
|
+
cwdHint: process.env.GROK_TG_CWD,
|
|
39
|
+
});
|
|
34
40
|
|
|
35
41
|
/** Absolute path to the `.env` this instance loads (and that `setup` writes). */
|
|
36
42
|
export const ENV_PATH = join(INSTANCE_DIR, ".env");
|
|
37
43
|
|
|
38
|
-
function resolveInstanceDir(): string {
|
|
39
|
-
const flag = process.argv.indexOf("--instance");
|
|
40
|
-
if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]!);
|
|
41
|
-
const envDir = process.env.GROK_TG_DIR?.trim() || process.env.GROK_TG_CWD?.trim();
|
|
42
|
-
if (envDir) return resolve(expandHome(envDir));
|
|
43
|
-
if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
|
|
44
|
-
return CANONICAL_DIR;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
44
|
// Load .env from the resolved instance directory. Keep the parsed values as
|
|
48
45
|
// well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex,
|
|
49
46
|
// Kiro, etc.) and must never override this Grok instance's identity.
|
|
50
47
|
const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {};
|
|
51
48
|
|
|
52
|
-
function expandHome(p: string): string {
|
|
53
|
-
if (p === "~") return homedir();
|
|
54
|
-
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
|
|
55
|
-
return p;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
49
|
function bool(v: string | undefined, def: boolean): boolean {
|
|
59
50
|
if (v === undefined || v === "") return def;
|
|
60
51
|
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
|
|
@@ -142,6 +133,20 @@ export interface AppConfig {
|
|
|
142
133
|
* Approve/Deny buttons.
|
|
143
134
|
*/
|
|
144
135
|
autoApprovePermissions: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Auto-approve Grok plan-mode exit (no Approve/Changes/Abandon buttons).
|
|
138
|
+
* Default true so unattended/24/7 bots never wait on a TUI. Set false for
|
|
139
|
+
* interactive review in Telegram.
|
|
140
|
+
*/
|
|
141
|
+
autoApprovePlan: boolean;
|
|
142
|
+
/** GROK_SANDBOX profile (workspace-safe, strict, off, …). */
|
|
143
|
+
sandboxProfile?: string;
|
|
144
|
+
/** GROK_MEMORY setting forwarded to the agent process. */
|
|
145
|
+
grokMemory?: string;
|
|
146
|
+
/** `--agent-profile` for `grok agent`. */
|
|
147
|
+
agentProfile?: string;
|
|
148
|
+
/** `--plugin-dir` for `grok agent`. */
|
|
149
|
+
pluginDir?: string;
|
|
145
150
|
projectRoots: string[];
|
|
146
151
|
streamThrottleMs: number;
|
|
147
152
|
messageBatchMs: number;
|
|
@@ -301,6 +306,11 @@ export function loadConfig(): AppConfig {
|
|
|
301
306
|
trustAllTools: bool(process.env.GROK_TRUST_ALL_TOOLS, true),
|
|
302
307
|
// Default true: auto-approve with session-scope when the agent still asks.
|
|
303
308
|
autoApprovePermissions: bool(process.env.AUTO_APPROVE_PERMISSIONS, true),
|
|
309
|
+
autoApprovePlan: bool(process.env.AUTO_APPROVE_PLAN, true),
|
|
310
|
+
sandboxProfile: process.env.GROK_SANDBOX?.trim() || undefined,
|
|
311
|
+
grokMemory: process.env.GROK_MEMORY?.trim() || undefined,
|
|
312
|
+
agentProfile: process.env.GROK_AGENT_PROFILE?.trim() || undefined,
|
|
313
|
+
pluginDir: process.env.GROK_PLUGIN_DIR?.trim() || undefined,
|
|
304
314
|
projectRoots: [...new Set(roots)],
|
|
305
315
|
streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500),
|
|
306
316
|
messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800),
|
package/src/grok/client.ts
CHANGED
|
@@ -218,6 +218,10 @@ export interface GrokClientOptions {
|
|
|
218
218
|
autoRestart?: boolean;
|
|
219
219
|
promptIdleTimeoutMs?: number;
|
|
220
220
|
promptMaxMs?: number;
|
|
221
|
+
sandboxProfile?: string;
|
|
222
|
+
grokMemory?: string;
|
|
223
|
+
agentProfile?: string;
|
|
224
|
+
pluginDir?: string;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
interface Pending {
|
|
@@ -288,6 +292,10 @@ export class GrokClient extends EventEmitter {
|
|
|
288
292
|
* outcomes). Must never kill the agent process.
|
|
289
293
|
*/
|
|
290
294
|
onSessionCancel?: (sessionId: string) => void;
|
|
295
|
+
/** Interactive (or auto) plan-mode exit. Default: auto-approve. */
|
|
296
|
+
planExitHandler?: (params: Record<string, unknown>) => Promise<unknown>;
|
|
297
|
+
/** Interactive (or skip) ask_user_question. Default: SkipInterview. */
|
|
298
|
+
askUserHandler?: (params: Record<string, unknown>) => Promise<unknown>;
|
|
291
299
|
|
|
292
300
|
constructor(private readonly opts: GrokClientOptions) {
|
|
293
301
|
super();
|
|
@@ -313,11 +321,15 @@ export class GrokClient extends EventEmitter {
|
|
|
313
321
|
// the new token. `--no-auto-update` was removed in grok 0.2.x (exit 2).
|
|
314
322
|
const args = ["agent", "--no-leader"];
|
|
315
323
|
if (this.opts.trustAllTools) args.push("--always-approve");
|
|
324
|
+
if (this.opts.agentProfile) args.push("--agent-profile", this.opts.agentProfile);
|
|
325
|
+
if (this.opts.pluginDir) args.push("--plugin-dir", this.opts.pluginDir);
|
|
316
326
|
args.push("stdio");
|
|
317
327
|
|
|
318
328
|
log.info(`spawning: ${this.opts.grokCliPath} ${args.join(" ")}`);
|
|
319
329
|
const env = { ...process.env };
|
|
320
330
|
if (this.opts.apiKey) env.XAI_API_KEY = this.opts.apiKey;
|
|
331
|
+
if (this.opts.sandboxProfile) env.GROK_SANDBOX = this.opts.sandboxProfile;
|
|
332
|
+
if (this.opts.grokMemory) env.GROK_MEMORY = this.opts.grokMemory;
|
|
321
333
|
const proc = spawn(this.opts.grokCliPath, args, {
|
|
322
334
|
stdio: ["pipe", "pipe", "pipe"],
|
|
323
335
|
cwd: this.opts.workspace,
|
|
@@ -419,7 +431,11 @@ export class GrokClient extends EventEmitter {
|
|
|
419
431
|
}
|
|
420
432
|
|
|
421
433
|
async newSession(cwd: string): Promise<string> {
|
|
422
|
-
const res = (await this.request("session/new", {
|
|
434
|
+
const res = (await this.request("session/new", {
|
|
435
|
+
cwd,
|
|
436
|
+
mcpServers: [],
|
|
437
|
+
...(this.opts.trustAllTools ? { _meta: { yoloMode: true } } : {}),
|
|
438
|
+
})) as { sessionId: string };
|
|
423
439
|
this.parseSessionExtras(res);
|
|
424
440
|
this.cwd.set(res.sessionId, cwd);
|
|
425
441
|
this.slog.create(res.sessionId, cwd);
|
|
@@ -622,6 +638,12 @@ export class GrokClient extends EventEmitter {
|
|
|
622
638
|
return this.request("_grok.dev/commands/execute", { sessionId, command });
|
|
623
639
|
}
|
|
624
640
|
|
|
641
|
+
/** Update spawn-time agent env (applied on the next `grok agent` restart). */
|
|
642
|
+
setAgentOptions(opts: { sandboxProfile?: string; grokMemory?: string }): void {
|
|
643
|
+
if (opts.sandboxProfile !== undefined) this.opts.sandboxProfile = opts.sandboxProfile;
|
|
644
|
+
if (opts.grokMemory !== undefined) this.opts.grokMemory = opts.grokMemory;
|
|
645
|
+
}
|
|
646
|
+
|
|
625
647
|
stop(): void {
|
|
626
648
|
this.stopped = true;
|
|
627
649
|
if (this.restartTimer) {
|
|
@@ -780,11 +802,13 @@ export class GrokClient extends EventEmitter {
|
|
|
780
802
|
(planSnippet ? ` plan=${planSnippet.replace(/\s+/g, " ").slice(0, 80)}` : "") +
|
|
781
803
|
(keys ? ` keys=[${keys}]` : ""),
|
|
782
804
|
);
|
|
783
|
-
result =
|
|
805
|
+
result = this.planExitHandler
|
|
806
|
+
? await this.planExitHandler(params)
|
|
807
|
+
: autoApproveExitPlanMode(params);
|
|
784
808
|
} else if (isAskUserQuestionMethod(method)) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
809
|
+
result = this.askUserHandler
|
|
810
|
+
? await this.askUserHandler(params)
|
|
811
|
+
: autoSkipAskUserQuestion(params);
|
|
788
812
|
} else {
|
|
789
813
|
// We advertise no fs/terminal capabilities, so the agent shouldn't ask.
|
|
790
814
|
// Log at warn — unknown reverse methods used to silently break plan exit
|
|
@@ -24,6 +24,14 @@ export const EXT_EXIT_PLAN_MODE_ALT = "x.ai/exit_plan_mode";
|
|
|
24
24
|
export const EXT_ASK_USER_QUESTION = "_x.ai/ask_user_question";
|
|
25
25
|
export const EXT_ASK_USER_QUESTION_ALT = "x.ai/ask_user_question";
|
|
26
26
|
|
|
27
|
+
/** Plan approval outcomes accepted by Grok Build's ExitPlanModeExtResponse. */
|
|
28
|
+
export type PlanExitOutcome = "approved" | "abandoned" | "request_changes";
|
|
29
|
+
|
|
30
|
+
export interface PlanExitDecision {
|
|
31
|
+
outcome: PlanExitOutcome;
|
|
32
|
+
feedback?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* Auto-approve leaving plan mode so the agent can implement.
|
|
29
37
|
*
|
package/src/index.ts
CHANGED
|
@@ -146,6 +146,10 @@ async function main(): Promise<void> {
|
|
|
146
146
|
model: cfg.grokModel,
|
|
147
147
|
autoRestart: cfg.grokAutoRestart,
|
|
148
148
|
promptIdleTimeoutMs: cfg.promptIdleMs,
|
|
149
|
+
sandboxProfile: cfg.sandboxProfile,
|
|
150
|
+
grokMemory: cfg.grokMemory,
|
|
151
|
+
agentProfile: cfg.agentProfile,
|
|
152
|
+
pluginDir: cfg.pluginDir,
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
// Retry ACP connect — agent crash at boot should not kill the Telegram bot.
|
package/src/service/linux.ts
CHANGED
|
@@ -11,8 +11,12 @@ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
|
11
11
|
|
|
12
12
|
const UNIT = "grok-telegram-bot.service";
|
|
13
13
|
|
|
14
|
-
function
|
|
15
|
-
return
|
|
14
|
+
function unitName(spec?: { id?: string }): string {
|
|
15
|
+
return spec?.id ? `${spec.id}.service` : UNIT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function unitPath(spec?: { id?: string }): string {
|
|
19
|
+
return join(homedir(), ".config", "systemd", "user", unitName(spec));
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export const linuxController: ServiceController = {
|
|
@@ -21,35 +25,37 @@ export const linuxController: ServiceController = {
|
|
|
21
25
|
async install(spec) {
|
|
22
26
|
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
23
27
|
mkdirSync(spec.logsDir, { recursive: true });
|
|
24
|
-
|
|
28
|
+
const unit = unitName(spec);
|
|
29
|
+
writeFileSync(unitPath(spec), unitFile(spec), "utf-8");
|
|
25
30
|
|
|
26
31
|
runSafe("systemctl", ["--user", "daemon-reload"]);
|
|
27
|
-
const en = runSafe("systemctl", ["--user", "enable", "--now",
|
|
32
|
+
const en = runSafe("systemctl", ["--user", "enable", "--now", unit]);
|
|
28
33
|
if (!en.ok) return fail(`systemctl enable failed: ${en.out}`);
|
|
29
34
|
const linger = runSafe("loginctl", ["enable-linger", userInfo().username]);
|
|
30
35
|
const note = linger.ok ? " Boot-without-login enabled (linger)." : " (run `loginctl enable-linger` for boot-without-login)";
|
|
31
|
-
return ok(`Installed and started systemd user service "${
|
|
36
|
+
return ok(`Installed and started systemd user service "${unit}".${note}`);
|
|
32
37
|
},
|
|
33
38
|
|
|
34
|
-
async uninstall() {
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
async uninstall(spec) {
|
|
40
|
+
const unit = unitName(spec);
|
|
41
|
+
runSafe("systemctl", ["--user", "disable", "--now", unit]);
|
|
42
|
+
rmSync(unitPath(spec), { force: true });
|
|
37
43
|
runSafe("systemctl", ["--user", "daemon-reload"]);
|
|
38
|
-
return ok(`Removed systemd user service "${
|
|
44
|
+
return ok(`Removed systemd user service "${unit}".`);
|
|
39
45
|
},
|
|
40
46
|
|
|
41
|
-
async start() {
|
|
42
|
-
const r = runSafe("systemctl", ["--user", "start",
|
|
47
|
+
async start(spec) {
|
|
48
|
+
const r = runSafe("systemctl", ["--user", "start", unitName(spec)]);
|
|
43
49
|
return r.ok ? ok("Started.") : fail(r.out);
|
|
44
50
|
},
|
|
45
51
|
|
|
46
|
-
async stop() {
|
|
47
|
-
const r = runSafe("systemctl", ["--user", "stop",
|
|
52
|
+
async stop(spec) {
|
|
53
|
+
const r = runSafe("systemctl", ["--user", "stop", unitName(spec)]);
|
|
48
54
|
return r.ok ? ok("Stopped.") : fail(r.out);
|
|
49
55
|
},
|
|
50
56
|
|
|
51
|
-
async status() {
|
|
52
|
-
const r = runSafe("systemctl", ["--user", "status",
|
|
57
|
+
async status(spec) {
|
|
58
|
+
const r = runSafe("systemctl", ["--user", "status", unitName(spec), "--no-pager"]);
|
|
53
59
|
return ok(r.out.trim() || "No status.");
|
|
54
60
|
},
|
|
55
61
|
};
|
package/src/service/macos.ts
CHANGED
|
@@ -10,8 +10,12 @@ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
|
10
10
|
|
|
11
11
|
const LABEL = "com.grok.telegrambot";
|
|
12
12
|
|
|
13
|
-
function
|
|
14
|
-
return
|
|
13
|
+
function labelOf(spec?: { macosLabel?: string }): string {
|
|
14
|
+
return spec?.macosLabel || LABEL;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function plistPath(spec?: { macosLabel?: string }): string {
|
|
18
|
+
return join(homedir(), "Library", "LaunchAgents", `${labelOf(spec)}.plist`);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export const macosController: ServiceController = {
|
|
@@ -20,32 +24,33 @@ export const macosController: ServiceController = {
|
|
|
20
24
|
async install(spec) {
|
|
21
25
|
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
22
26
|
mkdirSync(spec.logsDir, { recursive: true });
|
|
23
|
-
const path = plistPath();
|
|
27
|
+
const path = plistPath(spec);
|
|
24
28
|
runSafe("launchctl", ["unload", "-w", path]); // ignore if not loaded
|
|
25
29
|
writeFileSync(path, plist(spec), "utf-8");
|
|
26
30
|
const r = runSafe("launchctl", ["load", "-w", path]);
|
|
27
|
-
return r.ok ? ok(`Installed and loaded LaunchAgent "${
|
|
31
|
+
return r.ok ? ok(`Installed and loaded LaunchAgent "${labelOf(spec)}".`) : fail(r.out);
|
|
28
32
|
},
|
|
29
33
|
|
|
30
|
-
async uninstall() {
|
|
31
|
-
runSafe("launchctl", ["unload", "-w", plistPath()]);
|
|
32
|
-
rmSync(plistPath(), { force: true });
|
|
33
|
-
return ok(`Removed LaunchAgent "${
|
|
34
|
+
async uninstall(spec) {
|
|
35
|
+
runSafe("launchctl", ["unload", "-w", plistPath(spec)]);
|
|
36
|
+
rmSync(plistPath(spec), { force: true });
|
|
37
|
+
return ok(`Removed LaunchAgent "${labelOf(spec)}".`);
|
|
34
38
|
},
|
|
35
39
|
|
|
36
|
-
async start() {
|
|
37
|
-
const r = runSafe("launchctl", ["start",
|
|
40
|
+
async start(spec) {
|
|
41
|
+
const r = runSafe("launchctl", ["start", labelOf(spec)]);
|
|
38
42
|
return r.ok ? ok("Started.") : fail(r.out);
|
|
39
43
|
},
|
|
40
44
|
|
|
41
|
-
async stop() {
|
|
42
|
-
const r = runSafe("launchctl", ["stop",
|
|
45
|
+
async stop(spec) {
|
|
46
|
+
const r = runSafe("launchctl", ["stop", labelOf(spec)]);
|
|
43
47
|
return r.ok ? ok("Stopped.") : fail(r.out);
|
|
44
48
|
},
|
|
45
49
|
|
|
46
|
-
async status() {
|
|
50
|
+
async status(spec) {
|
|
51
|
+
const lab = labelOf(spec);
|
|
47
52
|
const r = runSafe("launchctl", ["list"]);
|
|
48
|
-
const line = r.out.split("\n").find((l) => l.includes(
|
|
53
|
+
const line = r.out.split("\n").find((l) => l.includes(lab));
|
|
49
54
|
return ok(line ? `Loaded: ${line.trim()}` : "Not loaded.");
|
|
50
55
|
},
|
|
51
56
|
};
|
|
@@ -67,7 +72,7 @@ function plist(spec: LaunchSpec): string {
|
|
|
67
72
|
'<plist version="1.0">',
|
|
68
73
|
"<dict>",
|
|
69
74
|
" <key>Label</key>",
|
|
70
|
-
` <string>${
|
|
75
|
+
` <string>${labelOf(spec)}</string>`,
|
|
71
76
|
" <key>ProgramArguments</key>",
|
|
72
77
|
" <array>",
|
|
73
78
|
args,
|