grok-telegram-bot 2.3.0 → 2.4.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 +26 -0
- package/CHANGELOG.md +55 -0
- package/package.json +1 -1
- package/scripts/analyze-jsonl.ts +33 -0
- package/scripts/delayed-restart.ps1 +29 -0
- package/scripts/probe-exit-response-shape.py +77 -0
- package/scripts/probe-plan-exit.py +60 -0
- package/scripts/probe-plan-exit2.py +48 -0
- package/scripts/probe-plan-fields.py +41 -0
- package/scripts/probe-plan-fields2.py +58 -0
- package/scripts/probe-plan-response-path.py +48 -0
- package/scripts/sample-claude-tooluse.ts +21 -0
- package/scripts/sample-kiro-events.ts +31 -0
- package/scripts/smoke-exit-plan.ts +274 -0
- package/scripts/smoke-exit-shapes.ts +252 -0
- package/scripts/smoke-import.mjs +82 -0
- package/scripts/smoke-import.ts +73 -0
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/types.ts +19 -2
- package/src/app/updater.ts +17 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +71 -2
- package/src/bot/bot.ts +36 -0
- package/src/bot/chat-controller.ts +35 -0
- package/src/bot/commands.ts +2 -0
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +19 -0
- package/src/bot/handlers/accounts.ts +55 -5
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +17 -38
- package/src/bot/handlers/message.ts +1 -0
- package/src/bot/handlers/running.ts +35 -5
- package/src/bot/handlers/session-card.ts +12 -0
- package/src/bot/handlers/sessions.ts +14 -3
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/menu/keyboard.ts +5 -4
- package/src/bot/menu/status-panel.ts +19 -6
- package/src/bot/prompt-content.ts +4 -0
- package/src/bot/reauth-controller.ts +2 -2
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +831 -64
- package/src/bot/suggestions.ts +429 -0
- package/src/config.ts +41 -0
- package/src/grok/client.ts +106 -20
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +179 -24
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +261 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +405 -142
- package/src/render/truncate.ts +85 -0
- package/src/service/windows.ts +14 -2
- package/src/sessions/history.ts +57 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +73 -9
- package/src/tasks/runner.ts +4 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List the sessions a sibling telegram-bot currently treats as /running
|
|
3
|
+
* (controlledSessions in its data/settings.json), enriched with on-disk meta
|
|
4
|
+
* and first-prompt titles when available.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { createLogger } from "../logger.js";
|
|
9
|
+
import { type ForeignSessionMeta, resolveForeignMeta } from "./history-readers.js";
|
|
10
|
+
import type { ImportSource } from "./sources.js";
|
|
11
|
+
|
|
12
|
+
const log = createLogger("import:list");
|
|
13
|
+
|
|
14
|
+
export interface ImportableSession extends ForeignSessionMeta {
|
|
15
|
+
/** Source tool id (kiro / opencode / …). */
|
|
16
|
+
sourceId: string;
|
|
17
|
+
/** True when listed from that bot's controlledSessions (/running). */
|
|
18
|
+
fromRunning: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RawControlled {
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
projectPath?: string;
|
|
24
|
+
projectName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RawChatSettings {
|
|
28
|
+
controlledSessions?: RawControlled[];
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
projectPath?: string;
|
|
31
|
+
projectName?: string;
|
|
32
|
+
foregroundSessionId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sessions currently controlled by the source bot (its /running list).
|
|
37
|
+
* When `chatId` is set, prefer that chat's controlled list; otherwise union
|
|
38
|
+
* every chat's controlled sessions (deduped by session id).
|
|
39
|
+
*/
|
|
40
|
+
export function listRunningFromSource(
|
|
41
|
+
src: ImportSource,
|
|
42
|
+
chatId?: number,
|
|
43
|
+
): ImportableSession[] {
|
|
44
|
+
const controlled = readControlled(src, chatId);
|
|
45
|
+
const out: ImportableSession[] = [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
|
|
48
|
+
for (const c of controlled) {
|
|
49
|
+
if (!c.sessionId || seen.has(c.sessionId)) continue;
|
|
50
|
+
seen.add(c.sessionId);
|
|
51
|
+
const meta = resolveForeignMeta(src.format, src.sessionsRoot, c.sessionId, {
|
|
52
|
+
cwd: c.projectPath,
|
|
53
|
+
projectName: c.projectName || (c.projectPath ? basename(c.projectPath) : undefined),
|
|
54
|
+
});
|
|
55
|
+
if (!meta.cwd && c.projectPath) meta.cwd = c.projectPath;
|
|
56
|
+
if (!meta.projectName) {
|
|
57
|
+
meta.projectName = c.projectName || (meta.cwd ? basename(meta.cwd) : undefined);
|
|
58
|
+
}
|
|
59
|
+
out.push({ ...meta, sourceId: src.id, fromRunning: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readControlled(src: ImportSource, chatId?: number): RawControlled[] {
|
|
66
|
+
if (!existsSync(src.settingsPath)) {
|
|
67
|
+
log.warn(`settings missing for ${src.id}: ${src.settingsPath}`);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
let raw: Record<string, RawChatSettings>;
|
|
71
|
+
try {
|
|
72
|
+
raw = JSON.parse(readFileSync(src.settingsPath, "utf-8")) as Record<string, RawChatSettings>;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
log.warn(`cannot parse settings for ${src.id}:`, (e as Error).message);
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const collect = (s: RawChatSettings | undefined): RawControlled[] => {
|
|
79
|
+
if (!s) return [];
|
|
80
|
+
const list = [...(s.controlledSessions ?? [])];
|
|
81
|
+
// Also include the single-session fields if controlledSessions is empty.
|
|
82
|
+
if (list.length === 0 && s.sessionId) {
|
|
83
|
+
list.push({
|
|
84
|
+
sessionId: s.sessionId,
|
|
85
|
+
projectPath: s.projectPath,
|
|
86
|
+
projectName: s.projectName,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return list;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (chatId !== undefined) {
|
|
93
|
+
const key = String(chatId);
|
|
94
|
+
if (raw[key]) return collect(raw[key]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const all: RawControlled[] = [];
|
|
98
|
+
for (const s of Object.values(raw)) all.push(...collect(s));
|
|
99
|
+
return all;
|
|
100
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreign session sources that can be imported into Grok: the four sibling
|
|
3
|
+
* Telegram bots on this machine. Each source has a bot root (for settings /
|
|
4
|
+
* /running controlled sessions) and a tool session store (history on disk).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export type ImportSourceId = "kiro" | "opencode" | "claude" | "codex";
|
|
11
|
+
|
|
12
|
+
/** How history is stored for a source tool. */
|
|
13
|
+
export type HistoryFormat = "acp-jsonl" | "codex-rollout" | "opencode-storage";
|
|
14
|
+
|
|
15
|
+
export interface ImportSource {
|
|
16
|
+
id: ImportSourceId;
|
|
17
|
+
/** Short label shown in the source picker. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Absolute path to the sibling telegram-bot project root. */
|
|
20
|
+
botRoot: string;
|
|
21
|
+
/** Path to that bot's settings.json (holds controlledSessions /running). */
|
|
22
|
+
settingsPath: string;
|
|
23
|
+
/**
|
|
24
|
+
* Tool session store root:
|
|
25
|
+
* - acp-jsonl: dir of `<id>.json` + `<id>.jsonl`
|
|
26
|
+
* - codex-rollout: `$CODEX_HOME/sessions` tree of rollouts
|
|
27
|
+
* - opencode-storage: `~/.local/share/opencode` (storage/ under it)
|
|
28
|
+
*/
|
|
29
|
+
sessionsRoot: string;
|
|
30
|
+
format: HistoryFormat;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DOMAINS = "H:\\Lucru\\Domains";
|
|
34
|
+
|
|
35
|
+
/** Built-in import sources (fixed paths as requested). */
|
|
36
|
+
export const IMPORT_SOURCES: readonly ImportSource[] = [
|
|
37
|
+
{
|
|
38
|
+
id: "kiro",
|
|
39
|
+
label: "Kiro",
|
|
40
|
+
botRoot: join(DOMAINS, "kiro-telegram-bot"),
|
|
41
|
+
settingsPath: join(DOMAINS, "kiro-telegram-bot", "data", "settings.json"),
|
|
42
|
+
sessionsRoot: join(homedir(), ".kiro", "sessions", "cli"),
|
|
43
|
+
format: "acp-jsonl",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "opencode",
|
|
47
|
+
label: "OpenCode",
|
|
48
|
+
botRoot: join(DOMAINS, "opencode-telegram-bot"),
|
|
49
|
+
settingsPath: join(DOMAINS, "opencode-telegram-bot", "data", "settings.json"),
|
|
50
|
+
sessionsRoot: join(homedir(), ".local", "share", "opencode"),
|
|
51
|
+
format: "opencode-storage",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "claude",
|
|
55
|
+
label: "Claude",
|
|
56
|
+
botRoot: join(DOMAINS, "claude-telegram-bot"),
|
|
57
|
+
settingsPath: join(DOMAINS, "claude-telegram-bot", "data", "settings.json"),
|
|
58
|
+
sessionsRoot: join(DOMAINS, "claude-telegram-bot", "data", "sessions"),
|
|
59
|
+
format: "acp-jsonl",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "codex",
|
|
63
|
+
label: "Codex",
|
|
64
|
+
botRoot: join(DOMAINS, "codex-telegram-bot"),
|
|
65
|
+
settingsPath: join(DOMAINS, "codex-telegram-bot", "data", "settings.json"),
|
|
66
|
+
sessionsRoot: join(homedir(), ".codex", "sessions"),
|
|
67
|
+
format: "codex-rollout",
|
|
68
|
+
},
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export function getImportSource(id: string): ImportSource | undefined {
|
|
72
|
+
return IMPORT_SOURCES.find((s) => s.id === id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when the bot root exists on disk. */
|
|
76
|
+
export function sourceAvailable(src: ImportSource): boolean {
|
|
77
|
+
return existsSync(src.botRoot);
|
|
78
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
* Grok Telegram Bot — entry point.
|
|
3
3
|
* Starts the Grok ACP bridge (`grok agent stdio`), the Telegram bot, and wires
|
|
4
4
|
* graceful shutdown between them.
|
|
5
|
+
*
|
|
6
|
+
* Lifetime rules (critical):
|
|
7
|
+
* - Prefer staying up over clean-but-dead exits.
|
|
8
|
+
* - Uncaught errors are logged; the process keeps polling Telegram.
|
|
9
|
+
* - grammY long-poll is restarted on transport/network death.
|
|
10
|
+
* - Supervised launches (GROK_TG_SUPERVISED=1 / service) may exit for relaunch;
|
|
11
|
+
* bare/manual runs re-exec when possible rather than dying silently.
|
|
5
12
|
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { Bot } from "grammy";
|
|
6
16
|
import { GrokClient } from "./grok/client.js";
|
|
7
17
|
import { createBot } from "./bot/bot.js";
|
|
8
|
-
import { CANONICAL_DIR, loadConfig } from "./config.js";
|
|
18
|
+
import { CANONICAL_DIR, INSTANCE_DIR, loadConfig } from "./config.js";
|
|
9
19
|
import { InstanceLock } from "./app/instance-lock.js";
|
|
10
|
-
import { join } from "node:path";
|
|
11
20
|
import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
|
|
12
21
|
|
|
13
22
|
async function main(): Promise<void> {
|
|
@@ -21,9 +30,10 @@ async function main(): Promise<void> {
|
|
|
21
30
|
// Single-instance guard: kill any ghost/duplicate already polling this token.
|
|
22
31
|
const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
|
|
23
32
|
if (cfg.singleInstance && !(await lock.acquire())) {
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
);
|
|
33
|
+
const msg =
|
|
34
|
+
"Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.";
|
|
35
|
+
log.warn(msg);
|
|
36
|
+
process.stdout.write(`\u26D4 ${msg}\n`);
|
|
27
37
|
process.exit(0);
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -33,6 +43,19 @@ async function main(): Promise<void> {
|
|
|
33
43
|
log.info(`sessions: ${cfg.sessionsDir}`);
|
|
34
44
|
log.info(`log file: ${cfg.logFile}`);
|
|
35
45
|
|
|
46
|
+
// Never die silently from stray exceptions — a dead bot is worse than a
|
|
47
|
+
// partially inconsistent one. Registering these handlers overrides Node's
|
|
48
|
+
// default "print and exit" for uncaughtException.
|
|
49
|
+
process.on("uncaughtException", (err) => {
|
|
50
|
+
log.error("uncaughtException (process stays up):", err);
|
|
51
|
+
});
|
|
52
|
+
process.on("unhandledRejection", (reason) => {
|
|
53
|
+
log.error(
|
|
54
|
+
"unhandledRejection (process stays up):",
|
|
55
|
+
reason instanceof Error ? reason : String(reason),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
36
59
|
const grok = new GrokClient({
|
|
37
60
|
grokCliPath: cfg.grokCliPath,
|
|
38
61
|
workspace: cfg.workspace,
|
|
@@ -44,20 +67,62 @@ async function main(): Promise<void> {
|
|
|
44
67
|
promptIdleTimeoutMs: cfg.promptIdleMs,
|
|
45
68
|
});
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
// Retry ACP connect — agent crash at boot should not kill the Telegram bot.
|
|
71
|
+
for (let attempt = 1; ; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
await grok.start();
|
|
74
|
+
break;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
77
|
+
log.error(`Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
|
|
78
|
+
await sleep(wait);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// createBot can fail on transient Telegram API issues — retry rather than die.
|
|
83
|
+
let bot: Bot;
|
|
84
|
+
let registry: Awaited<ReturnType<typeof createBot>>["registry"];
|
|
85
|
+
let scheduler: Awaited<ReturnType<typeof createBot>>["scheduler"];
|
|
86
|
+
let updater: Awaited<ReturnType<typeof createBot>>["updater"];
|
|
87
|
+
for (let attempt = 1; ; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
const bundle = await createBot(cfg, grok);
|
|
90
|
+
bot = bundle.bot;
|
|
91
|
+
registry = bundle.registry;
|
|
92
|
+
scheduler = bundle.scheduler;
|
|
93
|
+
updater = bundle.updater;
|
|
94
|
+
break;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
97
|
+
log.error(`createBot failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
|
|
98
|
+
await sleep(wait);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
scheduler!.start();
|
|
103
|
+
await updater!.start();
|
|
51
104
|
|
|
52
105
|
let shuttingDown = false;
|
|
53
106
|
const shutdown = (code: number): void => {
|
|
54
107
|
if (shuttingDown) return;
|
|
55
108
|
shuttingDown = true;
|
|
56
109
|
log.info("shutting down…");
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
110
|
+
try {
|
|
111
|
+
scheduler!.stop();
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
updater!.stop();
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore */
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
registry!.disposeAll();
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore */
|
|
124
|
+
}
|
|
125
|
+
void bot!.stop().catch(() => {});
|
|
61
126
|
grok.stop();
|
|
62
127
|
lock.release();
|
|
63
128
|
setTimeout(() => process.exit(code), 500);
|
|
@@ -67,18 +132,108 @@ async function main(): Promise<void> {
|
|
|
67
132
|
|
|
68
133
|
process.on("SIGINT", () => shutdown(0));
|
|
69
134
|
process.on("SIGTERM", () => shutdown(0));
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
|
|
136
|
+
// Refresh the single-instance lock periodically so a long-running process
|
|
137
|
+
// stays clearly "alive" on disk (start time + pid).
|
|
138
|
+
const lockHeartbeat = setInterval(() => {
|
|
139
|
+
try {
|
|
140
|
+
if (!shuttingDown) lock.touch();
|
|
141
|
+
} catch {
|
|
142
|
+
/* non-fatal */
|
|
143
|
+
}
|
|
144
|
+
}, 60_000);
|
|
145
|
+
lockHeartbeat.unref?.();
|
|
146
|
+
|
|
147
|
+
// Keep long-polling forever. On network / 409 / grammY fatal, wait and restart
|
|
148
|
+
// the poller instead of ending the process (silent death was a critical bug).
|
|
149
|
+
// If the inner loop still returns without shutdown, attempt self-relaunch; if
|
|
150
|
+
// that fails, resume polling rather than dying.
|
|
151
|
+
while (!shuttingDown) {
|
|
152
|
+
await runPollingForever(bot!, log, () => shuttingDown);
|
|
153
|
+
if (shuttingDown) break;
|
|
154
|
+
log.error("Telegram polling loop exited unexpectedly; attempting self-relaunch");
|
|
155
|
+
const replaced = await selfRelaunch(cfg.projectRoot, log);
|
|
156
|
+
if (replaced) {
|
|
157
|
+
clearInterval(lockHeartbeat);
|
|
158
|
+
shutdown(1);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
log.error("self-relaunch failed; resuming Telegram polling in 5s");
|
|
162
|
+
await sleep(5000);
|
|
163
|
+
}
|
|
164
|
+
clearInterval(lockHeartbeat);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** grammY start with automatic recovery. */
|
|
168
|
+
async function runPollingForever(
|
|
169
|
+
bot: Bot,
|
|
170
|
+
log: ReturnType<typeof createLogger>,
|
|
171
|
+
isShuttingDown: () => boolean,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
let attempt = 0;
|
|
174
|
+
while (!isShuttingDown()) {
|
|
175
|
+
try {
|
|
176
|
+
attempt = 0;
|
|
177
|
+
await bot.start({
|
|
178
|
+
onStart: (info) => {
|
|
179
|
+
log.info(`bot online as @${info.username}`);
|
|
180
|
+
process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
// bot.start resolves when polling is stopped (bot.stop).
|
|
184
|
+
if (isShuttingDown()) return;
|
|
185
|
+
log.warn("bot.start resolved without shutdown; restarting polling in 2s");
|
|
186
|
+
await sleep(2000);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
attempt++;
|
|
189
|
+
const wait = Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
|
|
190
|
+
log.error(
|
|
191
|
+
`Telegram polling failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`,
|
|
192
|
+
);
|
|
193
|
+
try {
|
|
194
|
+
await bot.stop();
|
|
195
|
+
} catch {
|
|
196
|
+
/* ignore */
|
|
197
|
+
}
|
|
198
|
+
await sleep(wait);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Spawn a fresh bot process then let the caller exit (manual / non-supervised).
|
|
205
|
+
* Returns true when a replacement was started (or supervised exit is expected).
|
|
206
|
+
*/
|
|
207
|
+
async function selfRelaunch(projectRoot: string, log: ReturnType<typeof createLogger>): Promise<boolean> {
|
|
208
|
+
if (process.env.GROK_TG_SUPERVISED === "1") {
|
|
209
|
+
log.info("supervised mode — exiting for external relaunch");
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const child = spawn(
|
|
214
|
+
process.execPath,
|
|
215
|
+
["--import", "tsx", join(projectRoot, "src", "index.ts"), "--instance", INSTANCE_DIR],
|
|
216
|
+
{ detached: true, stdio: "ignore", cwd: projectRoot, env: process.env },
|
|
217
|
+
);
|
|
218
|
+
child.unref();
|
|
219
|
+
if (!child.pid) {
|
|
220
|
+
log.error("self-relaunch spawn produced no pid — staying alive");
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
log.info(`spawned replacement pid ${child.pid}`);
|
|
224
|
+
return true;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
log.error(`self-relaunch failed: ${(e as Error).message} — staying alive`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sleep(ms: number): Promise<void> {
|
|
232
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
79
233
|
}
|
|
80
234
|
|
|
81
235
|
main().catch((err) => {
|
|
82
|
-
console.error("Fatal:", err instanceof Error ? err.message : err);
|
|
83
|
-
|
|
236
|
+
console.error("Fatal:", err instanceof Error ? err.stack || err.message : err);
|
|
237
|
+
// Delay exit so logs flush; VBS restart loop / supervisor can bring us back.
|
|
238
|
+
setTimeout(() => process.exit(1), 1000);
|
|
84
239
|
});
|
package/src/render/diff.ts
CHANGED
|
@@ -38,11 +38,20 @@ export function renderUnifiedDiff(input: DiffInput): DiffResult {
|
|
|
38
38
|
}
|
|
39
39
|
if (lines.length === 0) return { block: "", added, removed };
|
|
40
40
|
|
|
41
|
+
// Display-only: keep head + tail so long edits still show both the start and
|
|
42
|
+
// the end of the change. Full content remains in the agent session context.
|
|
41
43
|
let shown = lines;
|
|
42
44
|
let note = "";
|
|
43
45
|
if (lines.length > input.maxLines) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const headN = Math.max(1, Math.floor(input.maxLines * 0.45));
|
|
47
|
+
const tailN = Math.max(1, input.maxLines - headN);
|
|
48
|
+
const omitted = lines.length - headN - tailN;
|
|
49
|
+
shown = [
|
|
50
|
+
...lines.slice(0, headN),
|
|
51
|
+
`… (${omitted} lines omitted) …`,
|
|
52
|
+
...lines.slice(lines.length - tailN),
|
|
53
|
+
];
|
|
54
|
+
note = "";
|
|
46
55
|
}
|
|
47
56
|
return { block: "```diff\n" + shown.join("\n") + note + "\n```", added, removed };
|
|
48
57
|
}
|
|
@@ -25,7 +25,14 @@ export function fileOpFromUpdate(u: SessionUpdate): { path: string; op: FileOp }
|
|
|
25
25
|
const raw = (u.rawInput || {}) as Record<string, unknown>;
|
|
26
26
|
const diff = findDiff(u);
|
|
27
27
|
const path =
|
|
28
|
-
str(diff?.path) ||
|
|
28
|
+
str(diff?.path) ||
|
|
29
|
+
str(raw.path) ||
|
|
30
|
+
str(raw.file_path) ||
|
|
31
|
+
str(raw.filePath) ||
|
|
32
|
+
str(raw.target_file) ||
|
|
33
|
+
str(raw.targetFile) ||
|
|
34
|
+
str(raw.filename) ||
|
|
35
|
+
pathFromTitle(u.title);
|
|
29
36
|
if (!path) return undefined;
|
|
30
37
|
|
|
31
38
|
if (kind === "delete") return { path, op: "deleted" };
|
|
@@ -71,6 +78,29 @@ export function summarizeFileOpsShort(ops: Map<string, FileOp>): string {
|
|
|
71
78
|
return ops.size === 0 ? "\u{1F4C4} No files modified" : `\u{1F4DD} ${countsLine(ops)}`;
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Split Done body: first-turn edits vs self-recheck edits (when a recheck ran).
|
|
83
|
+
* Each section uses the same compact list format as {@link summarizeFileOps}.
|
|
84
|
+
*/
|
|
85
|
+
export function summarizeFileOpsSplit(
|
|
86
|
+
firstTurn: Map<string, FileOp>,
|
|
87
|
+
recheck: Map<string, FileOp>,
|
|
88
|
+
cwd: string,
|
|
89
|
+
maxList = 15,
|
|
90
|
+
): string {
|
|
91
|
+
const a = summarizeFileOps(firstTurn, cwd, maxList);
|
|
92
|
+
const b = summarizeFileOps(recheck, cwd, maxList);
|
|
93
|
+
return (
|
|
94
|
+
`\u{1F4C1} After first turn\n${a}` +
|
|
95
|
+
`\n\n\u{1F50D} After self-recheck\n${b}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clone a file-ops map (for freezing first-turn edits before recheck). */
|
|
100
|
+
export function cloneFileOps(ops: Map<string, FileOp>): Map<string, FileOp> {
|
|
101
|
+
return new Map(ops);
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
/** "+2 created · ~3 edited · −1 deleted" — only the non-zero buckets. */
|
|
75
105
|
function countsLine(ops: Map<string, FileOp>): string {
|
|
76
106
|
const counts: Record<FileOp, number> = { created: 0, edited: 0, deleted: 0, moved: 0 };
|