Log crash stacks, warmer /start, correct command detection, and stricter fallbacks (Group D)

- main.ts: unhandledRejection/uncaughtException also print the stack (or
  message) via console.error so it is not lost when the logger only prints a
  single line; bot.start(...) is no longer fire-and-forget -- a polling
  failure is logged and exits the process instead of failing silently.
- bridge.ts: /start now greets before showing the same status block as
  /status (split back into two command handlers). Command detection now
  checks for a bot_command entity at offset 0 instead of a leading "/", so a
  message that merely starts with a slash (e.g. a path) reaches the agent
  instead of getting "Unknown command.". An unsupported media message with
  no text and no other notes (sticker, location, video note, ...) now gets
  an explicit "I can handle text, photos, voice notes, and files." reply
  instead of silently doing nothing.
- outbound.ts sendMarkdown: only falls back to plain text for Telegram's
  400 "can't parse entities" style errors; any other error (network, etc.)
  propagates instead of being masked as a silent plain-text resend.
- runner.ts resolveShell: added the per-user Git for Windows bash.exe under
  %LOCALAPPDATA% as a third candidate; README's Windows section now
  mentions SECRETARY_SHELL for that case.
- docker-compose.yml: added init: true, since Node does not reap zombies
  left by killed script process groups.

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 18:09:21 +02:00
parent 9cf6f7385e
commit bf35eee736
8 changed files with 71 additions and 8 deletions

View File

@@ -62,7 +62,7 @@ The agent's edits to `MEMORY.md` are local runtime data. Leave them uncommitted,
## Keeping it running on Windows
Use Task Scheduler (run `node dist/main.js` at logon, restart on failure) or `npm i -g pm2` then `pm2 start dist/main.js --name secretary`.
Use Task Scheduler (run `node dist/main.js` at logon, restart on failure) or `npm i -g pm2` then `pm2 start dist/main.js --name secretary`. If Git is installed per-user or somewhere else entirely, set `SECRETARY_SHELL` to the full path of `bash.exe`.
## Docker / Coolify

View File

@@ -2,6 +2,9 @@ services:
secretary:
build: .
restart: unless-stopped
# Node does not reap zombies from killed script process groups (scheduled scripts run in
# their own detached process group so the whole tree can be signalled); an init process does.
init: true
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_OWNER_ID=${TELEGRAM_OWNER_ID}

View File

@@ -112,19 +112,24 @@ async function main(): Promise<void> {
});
process.on("unhandledRejection", (err) => {
logger.error("unhandled rejection", err);
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exit(1);
});
process.on("uncaughtException", (err) => {
logger.error("uncaught exception", err);
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exit(1);
});
const readyHost = host;
void bot.start({
bot.start({
onStart: () => {
logger.info("polling started");
void outbound.sendPlain(`🟢 Online.\n${formatStatus(readyHost, scheduler)}`).catch((err) => logger.warn("online message failed", err));
},
}).catch((err) => {
logger.error("polling stopped", err);
process.exit(1);
});
}

View File

@@ -1,5 +1,6 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
export type ScriptOutcome =
| { kind: "silent" }
@@ -45,6 +46,9 @@ class Capture {
const WINDOWS_BASH_CANDIDATES = [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
// Per-user Git for Windows installs here instead of Program Files. Skipped when LOCALAPPDATA
// is unset (path.join("", ...) would otherwise produce a relative, always-wrong candidate).
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe")] : []),
];
export function resolveShell(override?: string, platform: NodeJS.Platform = process.platform): string {

View File

@@ -88,8 +88,8 @@ function harness() {
}
let updateId = 1;
function textUpdate(text: string, from = OWNER, chatType = "private"): Update {
const entities = text.startsWith("/") ? [{ type: "bot_command" as const, offset: 0, length: text.split(" ")[0].length }] : undefined;
function textUpdate(text: string, from = OWNER, chatType = "private", withEntity = true): Update {
const entities = withEntity && text.startsWith("/") ? [{ type: "bot_command" as const, offset: 0, length: text.split(" ")[0].length }] : undefined;
return {
update_id: updateId++,
message: {
@@ -116,6 +116,7 @@ describe("registerBridge", () => {
expect(h.texts()[0]).toBe("🟢 Idle\nModel: anthropic/claude-x (thinking medium)\nContext: 12k / 200k tokens (6%)\nSession: abc.jsonl\nJobs: 1");
await h.bot.handleUpdate(textUpdate("/start"));
expect(h.texts()[1]).toContain("🟢 Idle");
expect(h.texts()[1].startsWith("Hi.")).toBe(true);
});
it("handles /stop, /new, /compact", async () => {
@@ -159,6 +160,31 @@ describe("registerBridge", () => {
expect(h.host.sent).toEqual([]);
});
it("treats leading-slash text without a bot_command entity as a normal message", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("/usr/local/bin is on my path", OWNER, "private", false));
expect(h.texts()).toEqual([]);
expect(h.host.sent).toEqual([{ text: "/usr/local/bin is on my path", images: undefined }]);
});
it("tells the owner it cannot handle unsupported media", async () => {
const h = harness();
const update = textUpdate("");
delete (update.message as { text?: string }).text;
(update.message as { sticker?: unknown }).sticker = {
file_id: "s1",
file_unique_id: "u",
type: "regular",
width: 1,
height: 1,
is_animated: false,
is_video: false,
};
await h.bot.handleUpdate(update);
expect(h.texts()).toEqual(["I can handle text, photos, voice notes, and files."]);
expect(h.host.sent).toEqual([]);
});
it("tells the owner when voice transcription is not configured", async () => {
const h = harness();
const update = textUpdate("");

View File

@@ -49,7 +49,11 @@ export function registerBridge(deps: BridgeDeps): void {
await next();
});
bot.command(["start", "status"], async (ctx) => {
bot.command("start", async (ctx) => {
await ctx.reply(`Hi. I'm your secretary. Send me text, photos, voice notes, or files.\n\n${formatStatus(host, scheduler)}`);
});
bot.command("status", async (ctx) => {
await ctx.reply(formatStatus(host, scheduler));
});
@@ -88,7 +92,8 @@ export function registerBridge(deps: BridgeDeps): void {
});
bot.on("message", async (ctx) => {
if (ctx.message.text?.startsWith("/")) {
const isCommand = ctx.message.entities?.some((e) => e.type === "bot_command" && e.offset === 0) ?? false;
if (isCommand) {
await ctx.reply("Unknown command.");
return;
}
@@ -99,7 +104,10 @@ export function registerBridge(deps: BridgeDeps): void {
});
for (const note of result.notes) await ctx.reply(note);
if (result.transcript) await ctx.reply(`🎤 ${result.transcript}`);
if (!result.input) return;
if (!result.input) {
if (result.notes.length === 0) await ctx.reply("I can handle text, photos, voice notes, and files.");
return;
}
if (host.busy) await ctx.react("👀").catch(() => undefined);
void host.send(result.input).catch((err) => {
logger.error("agent send failed", err);

View File

@@ -5,9 +5,15 @@ import { Outbound, type TelegramApiLike } from "./outbound.js";
class FakeApi implements TelegramApiLike {
calls: Array<{ method: string; args: unknown[] }> = [];
failHtml = false;
networkFailHtml = false;
nextId = 100;
async sendMessage(chatId: number, text: string, other?: { parse_mode?: "HTML" }) {
if (this.failHtml && other?.parse_mode === "HTML") throw new Error("Bad Request: can't parse entities");
if (this.failHtml && other?.parse_mode === "HTML") {
const err = new Error("Bad Request: can't parse entities") as Error & { error_code?: number };
err.error_code = 400;
throw err;
}
if (this.networkFailHtml && other?.parse_mode === "HTML") throw new Error("network");
this.calls.push({ method: "sendMessage", args: [chatId, text, other] });
return { message_id: this.nextId++ };
}
@@ -51,6 +57,13 @@ describe("Outbound", () => {
expect(api.calls).toEqual([{ method: "sendMessage", args: [7, "**hi**", undefined] }]);
});
it("propagates a non-400 error instead of falling back", async () => {
api.networkFailHtml = true;
const out = new Outbound(api, 7, logger);
await expect(out.sendMarkdown("**hi**")).rejects.toThrow("network");
expect(api.calls).toEqual([]);
});
it("splits long text into several messages", async () => {
const out = new Outbound(api, 7, logger);
await out.sendPlain("a".repeat(3800) + "\n\n" + "b".repeat(100));

View File

@@ -50,6 +50,10 @@ export class Outbound {
try {
await this.api.sendMessage(this.chatId, html, { parse_mode: "HTML" });
} catch (err) {
// Only fall back for Telegram's "can't parse entities" style 400s. Any other error
// (network, rate limit not absorbed by auto retry, etc.) is not the HTML's fault and
// should propagate instead of being silently swallowed as a plain-text resend.
if ((err as { error_code?: number }).error_code !== 400) throw err;
this.logger.warn("HTML message rejected, sending plain text", err);
await this.api.sendMessage(this.chatId, chunk);
}