diff --git a/.env.example b/.env.example index 9b1bcaf..6ee4b13 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,4 @@ ELECTROBUN_ENABLE_CODESIGN=false ELECTROBUN_ENABLE_NOTARIZE=false # Optional: enable sanitized hook debug logs locally -LOOPNDROLL_ENABLE_HOOK_DEBUG_LOGS=true +LOOPNDROLL_ENABLE_HOOK_DEBUG_LOGS=false diff --git a/README.md b/README.md index db902ab..2abe821 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,237 @@ +

Loopndroll

+

Let Codex run until the task is actually done.

+

Download current published release

+https://github.com/user-attachments/assets/1deba634-a305-4686-8654-65f889162932 +Loopndroll is a local macOS app for keeping Codex chats moving after Codex tries to stop. It uses Codex Hooks to observe chats, decide what should happen at Stop, send notifications, and optionally feed a Telegram reply back into the same Codex chat when the chat is already waiting. -

Loopndroll

+It is useful when you want Codex to keep going through a long task, prove checks before stopping, or wait for a human decision in Telegram instead of guessing. -

Let Codex run until the task is actually done.

-

Download

+## Features -https://github.com/user-attachments/assets/1deba634-a305-4686-8654-65f889162932 +- Keep Codex running with a configurable continue prompt. +- Wait for a Telegram reply before continuing a Codex chat. +- Run completion checks, such as tests, lint, or typecheck, before allowing a chat to stop. +- Limit continuation to one, two, or three extra turns. +- Send Codex Stop replies to Telegram and Slack. +- Mirror observed Codex user prompts and assistant Stop replies to connected channels. +- Configure modes globally or per Codex chat. +- Attach Telegram or Slack destinations globally or per chat. +- Store new Telegram bot tokens and Slack webhook URLs in macOS Keychain. +- Manage Loopndroll's own Codex hooks without deleting unrelated hooks. -If you've ever had to send dozens of follow-up messages just to keep Codex running, or felt frustrated when it skipped tests, lint, or typecheck even though you clearly asked for them in Agents.md, this might help. +## Safety model -## With Loopndroll, you can: +Loopndroll runs locally on your machine. It does not send your chats, prompts, app database, or hook state to a Loopndroll server. -- keep Codex running until you stop it -- require specific commands at the end of a task, and keep going until they pass -- get progress updates in Telegram or Slack, and even reply to redirect the work or change the mode +If you connect Telegram or Slack, delivery goes through your own Telegram bot token or your own Slack Incoming Webhook URL. Those provider endpoints are controlled by you. -## How does it work +Important safety defaults: -Loopndroll plugs into Codex through Codex Hooks. +- New Telegram bot tokens are stored in macOS Keychain. +- New Slack webhook URLs are stored in macOS Keychain. +- The local SQLite database stores non-secret Keychain references for new secrets. +- Telegram control is direct-message only. +- Telegram groups and channels are filtered out for control. +- Slack is outbound-only in v1. +- Passive wake is disabled in v1. +- Telegram input does not wake idle Codex threads. +- If no hook-backed channel is active, Telegram input is not delivered to Codex. +- Pause is a soft disable: hooks remain installed but Loopndroll stays inert. +- Stop/Clear removes only Loopndroll-managed hook entries. + +## How it works -When a chat starts, Loopndroll registers it and remembers the settings for that task. +Loopndroll plugs into Codex through Codex Hooks. -When Codex tries to stop, Loopndroll gets a chance to decide what should happen next. Depending on the mode you picked, it can: +When a Codex chat starts or resumes, Loopndroll registers the chat locally. When Codex reaches Stop, Loopndroll evaluates the active mode and either lets Codex stop or returns a follow-up prompt that keeps the chat going. -- let the chat stop -- send another prompt and keep Codex going -- run your completion checks first, and keep going if they fail -- wait for a reply from Telegram and feed that reply back into the same chat +Depending on the mode, Loopndroll can: -At the same time, it can send the latest assistant message to Telegram or Slack so you can see progress without sitting in front of Codex the whole time. +- let the chat stop normally; +- send the default continue prompt; +- send a Telegram-provided prompt; +- run completion checks and continue if they fail; +- wait for a Telegram reply while Codex is already stopped inside an active hook; +- notify Telegram or Slack with the latest assistant message. -**IMPORTANT:** Loopndroll runs fully locally on your machine. It does not send your chats, prompts, or app data to any Loopndroll server. If you connect Telegram or Slack, you are using **your own bot** or **your own webhook**, under your control. +Loopndroll does not use Telegram or Slack as a general remote shell. Remote input is deliberately limited to active, hook-backed flows. ## Modes -You can set a mode globally for all chats, or override it per task. +You can set a mode globally for all chats, or override it per chat. If no mode is active, Codex stops normally. -- **Infinite**: every time Codex stops, Loopndroll sends the default follow-up prompt and keeps the chat going. You can change that default prompt in Settings, or override it for one task by replying to that task in Telegram. -- **Await Reply**: when Codex stops, Loopndroll waits for your reply in Telegram, then sends that reply back into the same chat. -- **Completion Checks**: when Codex stops, Loopndroll runs your commands like tests, lint, or typecheck. If any command fails, it tells Codex to keep going until they pass. +- **Infinite**: every time Codex stops, Loopndroll sends the default continue prompt and keeps the chat going. You can edit the default prompt in Settings. +- **Await Reply**: when Codex stops, Loopndroll sends a Telegram notification and waits for your Telegram reply. The reply is then sent back into the same Codex chat. +- **Completion Checks**: when Codex stops, Loopndroll runs configured commands such as tests, lint, or typecheck. If any command fails, Loopndroll tells Codex to keep going. - **Max Turns 1 / 2 / 3**: Loopndroll keeps Codex going for a fixed number of extra turns, then lets it stop. +- **Off**: Loopndroll does not continue the chat and does not accept Telegram input for that chat. + +## What v1 does not do + +Loopndroll v1 does not wake idle Codex threads from Telegram. + +That means a Telegram message cannot currently open chat `C2` and start a new Codex turn as if you typed into the Codex UI. Telegram input only works when Loopndroll already has a safe hook-backed channel, such as Await Reply waiting inside a Stop hook. + +This is intentional. Starting a new Codex turn from an idle chat needs a reliable supported input surface from Codex. Loopndroll v1 keeps the product boundary conservative and safe instead of pretending that Telegram can wake idle chats. + +## Notifications and mirror mode + +Control-mode notifications require an active Loopndroll mode. + +Mirror mode is separate. When enabled, Loopndroll mirrors observed Codex user prompts and assistant Stop replies to attached Telegram and Slack destinations. Mirror mode is output-only: Telegram replies are still ignored unless Await Reply is active. -This gives you a simple choice: keep pushing automatically, wait for human input, require checks to pass, or allow only a small number of extra turns. +Default behavior when everything is off: -## Use cases +- no Stop notifications; +- no mirror messages; +- no Telegram replies delivered to Codex; +- no pending prompts kept for inactive chats. -- **Keep pushing on a messy refactor without making me send "keep going" every 5 minutes** - Use **Infinite** when the work is real, but there is no clean automatic way to evaluate "done" yet. This fits tasks like cross-file refactors, bug hunts, and long review-comment cleanup where the next step depends on what Codex finds. +Administrative Telegram commands such as `/status`, `/help`, `/list`, `/mode`, and `/failsafe` may still respond because they control the integration itself. -- **Make sure `pnpm test` passes before marking the task as done** - Use **Completion Checks** when you want Codex to stop only after the repo is actually green. This is for the common case where the agent says it is done, but tests, lint, or typecheck still fail. +## Hook lifecycle -- **Send me the result in Telegram and wait for my decision** - Use **Await Reply** when Codex reaches a decision point and should wait for you instead of guessing. This works well when you want to review a draft, approve a plan, or redirect the work while you are away from your desk. +Loopndroll manages only its own Codex hook entries. -## Telegram Setup +Codex can load matching hooks from both global and repo-local hook files: -### Get a Telegram bot token +- `~/.codex/hooks.json` +- `/.codex/hooks.json` + +Loopndroll treats hook installation as a multi-file surface and does not claim all hooks are removed unless its own managed entries are removed from the relevant hook files. + +Runtime states: + +- **Running**: Loopndroll-managed hooks are installed and active. +- **Paused**: managed hooks remain installed, but Loopndroll stays inert and avoids remote-control side effects. +- **Stopped**: Loopndroll removes only its own managed hook entries and stops responding until started again. + +Changing hook files does not prove that an already-live Codex runtime has hot-unloaded those hooks. Loopndroll exposes that distinction in the app instead of pretending file state and live runtime state are the same. + +## Startup recovery + +If the previous app process exits without graceful cleanup and leaves its runtime marker behind, Loopndroll clears inherited active loop state on startup. This avoids relaunching into stale active modes or keeping old pending Telegram prompts alive by accident. A normal quit preserves configured modes. + +## Telegram setup + +### Create a Telegram bot 1. Open Telegram. 2. Start a chat with [`@BotFather`](https://t.me/BotFather). 3. Send `/newbot`. -4. Follow the prompts to choose a bot name and username. -5. BotFather will send you a bot token. It looks like `123456789:AA...`. -6. In Loop N Roll, go to `Settings` -> `Notifications` -> `Add Notification`. -7. Choose `Telegram`. -8. Paste the bot token into `API Token`. - -### Get your Telegram chat to show up in the app - -1. Open a direct message with your bot and send any message. -2. Or add the bot to a group and send any message in that group. -3. Go back to Loopndroll. -4. The chat should appear in the `Chat` dropdown. -5. Select it and save the notification. - -## Telegram Commands - -These commands work in Telegram after your bot is connected: - -- `/help` - show the command help -- `/list` - list chats registered to this Telegram destination -- `/status` - show the current global mode and per-chat modes -- `/reply C22 your message` - send a message to one specific chat -- `/mode global infinite` - set the global mode to Infinite -- `/mode global await` - set the global mode to Await Reply -- `/mode global checks` - set the global mode to Completion Checks -- `/mode global off` - turn off the global mode -- `/mode C22 infinite` - set chat `C22` to Infinite -- `/mode C22 await` - set chat `C22` to Await Reply -- `/mode C22 checks` - set chat `C22` to Completion Checks -- `/mode C22 off` - stop chat `C22` +4. Choose the bot display name. +5. Choose the bot username. Telegram requires it to end in `bot`. +6. Copy the bot token. It looks like `:`. +7. In Loopndroll, open `Settings`. +8. Open `Telegram setup instructions` if you want the checklist. +9. Click `Add Telegram Notification`. +10. Paste the token into `API Token`. + +Loopndroll stores newly saved Telegram bot tokens in macOS Keychain. + +Use one Telegram bot token per machine. Telegram polling cursors are token-scoped, so sharing one bot token across multiple Loopndroll installs can make replies appear on the wrong machine or disappear from one install after another install consumes the update. + +### Make your chat appear + +1. Open a direct message with your new bot. +2. Send any message to the bot. +3. Return to Loopndroll. +4. Load/select the direct-message chat. +5. Save the notification. +6. Attach the notification globally or to specific chats from Home. + +Telegram control is direct-message only in v1. Groups and channels are filtered out for safety. + +## Telegram commands + +These commands work after your bot is connected: + +- `/help` - show command help. +- `/list` - list chats registered to this Telegram destination. +- `/status` - show system state, global mode, and per-chat modes. +- `/reply C22 your message` - fallback: send a message to a specific registered chat. +- `/mode global infinite` - set the global mode to Infinite. +- `/mode global await` - set the global mode to Await Reply. +- `/mode global checks` - set the global mode to Completion Checks. +- `/mode global off` - turn off the global mode. +- `/mode C22 infinite` - set chat `C22` to Infinite. +- `/mode C22 await` - set chat `C22` to Await Reply. +- `/mode C22 checks` - set chat `C22` to Completion Checks. +- `/mode C22 off` - turn off chat `C22`. +- `/failsafe C22` - disable control for one chat and clear its pending prompts. +- `/failsafe all` - disable global mode, all per-chat modes, and pending prompts. Notes: -- If you reply directly to a Telegram notification, Loopndroll uses that chat automatically. -- If you send plain text without a command, Loopndroll sends it to the latest waiting chat in that Telegram conversation. +- Replying directly to a Loopndroll Telegram notification targets that Codex chat. +- Plain text without a command targets the latest safe Telegram-linked chat only when that chat has an active mode. +- If the target chat is Off, Loopndroll reports that nothing was delivered. +- If there is no safe active channel, Loopndroll does not wake Codex in v1. -## Slack Setup +## Slack setup -### Important +Loopndroll uses Slack Incoming Webhooks. -This app uses a Slack Incoming Webhook URL. +It does not use a Slack bot token in v1, and it does not receive inbound Slack messages. -It does **not** use a Slack bot token. +Why Slack is outbound-only: -### Get the Slack webhook URL +- An Incoming Webhook is a one-way Slack URL for posting messages into a channel. +- It does not deliver channel messages back to Loopndroll. +- Inbound Slack control would require a Slack app with events, permissions, signing-secret verification, and a reachable HTTP endpoint. +- Loopndroll v1 is local-first and does not require a public server, so Slack stays outbound-only. + +### Create a Slack Incoming Webhook 1. Go to [Slack Apps](https://api.slack.com/apps). 2. Create a new app, or open an existing app. 3. Open `Incoming Webhooks`. 4. Turn Incoming Webhooks on. 5. Click `Add New Webhook to Workspace`. -6. Pick the channel where you want messages posted. +6. Pick the channel where Loopndroll should post messages. 7. Approve the app. 8. Copy the webhook URL. It looks like `https://hooks.slack.com/services/...`. -9. In Loopndroll, go to `Settings` -> `Notifications` -> `Add Notification`. -10. Choose `Slack`. -11. Paste the webhook URL into `Webhook URL`. +9. In Loopndroll, open `Settings`. +10. Open `Slack setup instructions` if you want the checklist. +11. Click `Add Slack Notification`. +12. Paste the webhook URL into `Webhook URL`. + +Loopndroll stores newly saved Slack webhook URLs in macOS Keychain. + +## Secret migration + +Older local databases may contain plaintext Telegram bot tokens or Slack webhook URLs. Settings includes a Secret migration card that moves legacy plaintext secrets into macOS Keychain and keeps only non-secret references in the database. + +## Install and updates + +Install Loopndroll from a versioned release artifact. Public download links should point to a specific tag, for example `/releases/download/v1.1.5/...`, not GitHub's mutable `/releases/latest/download/...` shortcut. + +Release builds can still use Electrobun's auto-update mechanism. The app reads its configured release feed, checks for `update.json`, downloads an update, and applies it after you choose `Restart to Update`. Settings shows the current version, channel, release feed, last check time, and update status. + +For release maintainers: -If you were looking for a Slack token: this app does not need one for Slack notifications. +- `RELEASE_BASE_URL` controls the auto-update feed embedded in the release build. +- `scripts/release-macos.sh` defaults to Electrobun's GitHub Releases feed. +- Direct install links in documentation should stay version-pinned. +- Prefer a controlled stable feed plus signed release artifacts when you need stronger protection against mutable-feed compromise. ## Development -- `pnpm install` - install dependencies -- `pnpm run dev` - start the app in development mode -- `pnpm run check` - run lint, format check, and typecheck -- `pnpm run build` - build the app -- `pnpm run build:stable` - build the release version +- `pnpm install` - install dependencies. +- `pnpm run dev` - start the app in development mode. +- `pnpm run check` - run lint, format check, and typecheck. +- `pnpm run build` - build the app. +- `pnpm run build:stable` - build the stable release app. +- `RELEASE_BASE_URL= pnpm run release:macos` - build, sign, notarize, and publish a release. -## Useful Links +## Useful links - Telegram BotFather: [https://t.me/BotFather](https://t.me/BotFather) - Telegram Bot API: [https://core.telegram.org/bots/api](https://core.telegram.org/bots/api) - Slack apps: [https://api.slack.com/apps](https://api.slack.com/apps) -- Slack incoming webhooks: [https://api.slack.com/messaging/webhooks](https://api.slack.com/messaging/webhooks) +- Slack Incoming Webhooks: [https://api.slack.com/messaging/webhooks](https://api.slack.com/messaging/webhooks) diff --git a/electrobun.config.ts b/electrobun.config.ts index e3cba9b..c86191c 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -4,7 +4,9 @@ import { readFileSync } from "node:fs"; const releaseBaseUrl = process.env["RELEASE_BASE_URL"] || ""; const enableCodesign = process.env["ELECTROBUN_ENABLE_CODESIGN"] === "true"; const enableNotarize = process.env["ELECTROBUN_ENABLE_NOTARIZE"] === "true"; -const packageVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; +const packageVersion = JSON.parse( + readFileSync(new URL("./package.json", import.meta.url), "utf8"), +).version; export default { app: { @@ -23,21 +25,25 @@ export default { copy: { "dist/index.html": "views/app/index.html", "dist/assets": "views/app/assets", + "dist/fonts": "views/app/fonts", }, watchIgnore: ["dist/**"], mac: { codesign: enableCodesign, notarize: enableNotarize, bundleCEF: false, - icons: "build/icon.iconset", + icons: undefined, }, linux: { bundleCEF: false, - icon: "build/icon.png", + icon: "src/assets/app-icon.png", }, win: { bundleCEF: false, - icon: "build/icon.png", + icon: "src/assets/app-icon.png", }, }, + scripts: { + postBuild: "scripts/copy-macos-app-icon.ts", + }, } satisfies ElectrobunConfig; diff --git a/package.json b/package.json index 9dc2f61..a0b3619 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,13 @@ "private": true, "type": "module", "packageManager": "pnpm@10.33.0", + "pnpm": { + "overrides": { + "basic-ftp": "5.3.0", + "hono": "4.12.14", + "@hono/node-server": "1.19.13" + } + }, "description": "Keep Codex running forever", "scripts": { "dev": "concurrently -k -n vite,app \"pnpm run hmr\" \"pnpm run dev:app\"", @@ -14,13 +21,13 @@ "build:dev": "vite build && electrobun build", "build:stable": "vite build && electrobun build --env=stable", "release:macos": "bash scripts/release-macos.sh", - "build": "pnpm run build:dev", + "build": "vite build && electrobun build", "lint": "oxlint src electrobun.config.ts vite.config.ts --deny-warnings", "lint:fix": "oxlint src electrobun.config.ts vite.config.ts --fix", "format": "oxfmt src electrobun.config.ts vite.config.ts", "format:check": "oxfmt --check src electrobun.config.ts vite.config.ts", "typecheck": "tsgo --noEmit -p tsconfig.json", - "check": "pnpm run lint && pnpm run format:check && pnpm run typecheck" + "check": "oxlint src electrobun.config.ts vite.config.ts --deny-warnings && oxfmt --check src electrobun.config.ts vite.config.ts && tsgo --noEmit -p tsconfig.json" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85d59fc..318cae0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + basic-ftp: 5.3.0 + hono: 4.12.14 + '@hono/node-server': 1.19.13 + importers: .: @@ -461,11 +466,11 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@hono/node-server@1.19.12': - resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} + '@hono/node-server@1.19.13': + resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.14 '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} @@ -1255,8 +1260,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.2.0: - resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} + basic-ftp@5.3.0: + resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} engines: {node: '>=10.0.0'} better-sqlite3@12.9.0: @@ -1872,8 +1877,8 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hono@4.12.11: - resolution: {integrity: sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -3315,9 +3320,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@hono/node-server@1.19.12(hono@4.12.11)': + '@hono/node-server@1.19.13(hono@4.12.14)': dependencies: - hono: 4.12.11 + hono: 4.12.14 '@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))': dependencies: @@ -3377,7 +3382,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.12(hono@4.12.11) + '@hono/node-server': 1.19.13(hono@4.12.14) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -3387,7 +3392,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.11 + hono: 4.12.14 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -3864,7 +3869,7 @@ snapshots: baseline-browser-mapping@2.10.16: {} - basic-ftp@5.2.0: {} + basic-ftp@5.3.0: {} better-sqlite3@12.9.0: dependencies: @@ -4405,7 +4410,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.2.0 + basic-ftp: 5.3.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -4434,7 +4439,7 @@ snapshots: headers-polyfill@4.0.3: {} - hono@4.12.11: {} + hono@4.12.14: {} http-errors@2.0.1: dependencies: diff --git a/scripts/copy-macos-app-icon.ts b/scripts/copy-macos-app-icon.ts new file mode 100644 index 0000000..3d5de02 --- /dev/null +++ b/scripts/copy-macos-app-icon.ts @@ -0,0 +1,26 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const wrapperBundlePath = process.env["ELECTROBUN_WRAPPER_BUNDLE_PATH"]; +const buildDir = process.env["ELECTROBUN_BUILD_DIR"]; +const appName = process.env["ELECTROBUN_APP_NAME"]; +const appBundleName = appName?.endsWith(".app") ? appName : `${appName}.app`; +const bundlePath = + wrapperBundlePath ?? (buildDir && appName ? join(buildDir, appBundleName) : undefined); + +if (!bundlePath) { + throw new Error( + "ELECTROBUN_WRAPPER_BUNDLE_PATH or ELECTROBUN_BUILD_DIR/ELECTROBUN_APP_NAME is required", + ); +} + +const sourcePath = join(process.cwd(), "src/assets/AppIcon.icns"); +const destinationPath = join(bundlePath, "Contents/Resources/AppIcon.icns"); + +if (!existsSync(sourcePath)) { + throw new Error(`Missing app icon asset: ${sourcePath}`); +} + +mkdirSync(dirname(destinationPath), { recursive: true }); +copyFileSync(sourcePath, destinationPath); +console.log(`Copied macOS app icon to ${destinationPath}`); diff --git a/scripts/extract-installed-app-icon.swift b/scripts/extract-installed-app-icon.swift new file mode 100644 index 0000000..0d035f1 --- /dev/null +++ b/scripts/extract-installed-app-icon.swift @@ -0,0 +1,63 @@ +import AppKit +import Foundation + +let fileManager = FileManager.default +let projectRoot = URL(fileURLWithPath: fileManager.currentDirectoryPath) +let defaultSource = "/Applications/Loopndroll.app/Contents/Resources/AppIcon.icns" +let sourcePath = CommandLine.arguments.dropFirst().first ?? defaultSource +let sourceURL = URL(fileURLWithPath: sourcePath) +let assetsURL = projectRoot.appendingPathComponent("src/assets", isDirectory: true) +let icnsURL = assetsURL.appendingPathComponent("AppIcon.icns") +let pngURL = assetsURL.appendingPathComponent("app-icon.png") + +guard let sourceImage = NSImage(contentsOf: sourceURL) else { + FileHandle.standardError.write( + "Could not read installed app icon at \(sourcePath)\n".data(using: .utf8)!, + ) + exit(1) +} + +try fileManager.createDirectory(at: assetsURL, withIntermediateDirectories: true) +if fileManager.fileExists(atPath: icnsURL.path) { + try fileManager.removeItem(at: icnsURL) +} +try fileManager.copyItem(at: sourceURL, to: icnsURL) + +func pngData(from image: NSImage, pixels: Int) -> Data { + guard + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixels, + pixelsHigh: pixels, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0, + ) + else { + fatalError("Could not create bitmap for \(pixels)x\(pixels)") + } + + bitmap.size = NSSize(width: pixels, height: pixels) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + image.draw( + in: NSRect(x: 0, y: 0, width: pixels, height: pixels), + from: .zero, + operation: .copy, + fraction: 1.0, + ) + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation(using: .png, properties: [:]) else { + fatalError("Could not encode PNG for \(pixels)x\(pixels)") + } + return data +} + +try pngData(from: sourceImage, pixels: 1024).write(to: pngURL) + +print("Extracted installed app icon from \(sourcePath)") diff --git a/scripts/sacrificial_static_check_probe.ts b/scripts/sacrificial_static_check_probe.ts new file mode 100644 index 0000000..7c73d68 --- /dev/null +++ b/scripts/sacrificial_static_check_probe.ts @@ -0,0 +1,97 @@ +import { buildSacrificialStaticCheckPlan } from "../src/bun/sacrificial-static-check"; + +type SacrificialStaticCheckProbeArgs = { + mode: string; + cwd: string | null; + sandboxRoot: string | null; +}; + +function parseArgs(argv: string[]): SacrificialStaticCheckProbeArgs { + let mode = "readiness"; + let cwd: string | null = null; + let sandboxRoot: string | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (token === "--mode") { + mode = argv[index + 1] ?? mode; + index += 1; + continue; + } + + if (token === "--cwd") { + cwd = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (token === "--sandbox-root") { + sandboxRoot = argv[index + 1] ?? null; + index += 1; + } + } + + return { mode, cwd, sandboxRoot }; +} + +function printProbeOutput(payload: Record) { + console.log(JSON.stringify(payload, null, 2)); +} + +function getSandboxWorkspaceRoot(sandboxRoot: string) { + return `${sandboxRoot.replace(/\/+$/, "")}/workspace`; +} + +function isWithinSandboxWorkspace(cwd: string, sandboxRoot: string) { + const workspaceRoot = getSandboxWorkspaceRoot(sandboxRoot); + return cwd === workspaceRoot || cwd.startsWith(`${workspaceRoot}/`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + try { + const plan = buildSacrificialStaticCheckPlan({ + cwd: args.cwd ?? "", + sandboxRoot: args.sandboxRoot ?? "", + }); + + if (args.mode === "run-check" && !isWithinSandboxWorkspace(plan.cwd, plan.sandboxRoot)) { + printProbeOutput({ + status: "blocked", + mode: "run-check", + cwd: plan.cwd, + sandboxRoot: plan.sandboxRoot, + lane: plan.lane, + command: plan.command, + reason: "repo-outside-sandbox-workspace", + detail: + "The repo cwd is outside the sacrificial sandbox workspace, and this lane forbids install/materialization into the sandbox.", + allowHostGlobalPnpmFallback: plan.allowHostGlobalPnpmFallback, + allowInstallOrMaterialization: plan.allowInstallOrMaterialization, + }); + return; + } + + printProbeOutput({ + status: "ready", + mode: args.mode, + cwd: plan.cwd, + sandboxRoot: plan.sandboxRoot, + lane: plan.lane, + command: plan.command, + allowHostGlobalPnpmFallback: plan.allowHostGlobalPnpmFallback, + allowInstallOrMaterialization: plan.allowInstallOrMaterialization, + }); + } catch (error) { + printProbeOutput({ + status: "blocked", + cwd: args.cwd, + sandboxRoot: args.sandboxRoot, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +await main(); diff --git a/scripts/sacrificial_workspace_projection_probe.ts b/scripts/sacrificial_workspace_projection_probe.ts new file mode 100644 index 0000000..c343eea --- /dev/null +++ b/scripts/sacrificial_workspace_projection_probe.ts @@ -0,0 +1,97 @@ +import { buildSacrificialWorkspaceProjectionPlan } from "../src/bun/sacrificial-workspace-projection"; +import { stat } from "node:fs/promises"; + +type SacrificialWorkspaceProjectionProbeArgs = { + mode: string; + cwd: string | null; + sandboxRoot: string | null; +}; + +function parseArgs(argv: string[]): SacrificialWorkspaceProjectionProbeArgs { + let mode = "readiness"; + let cwd: string | null = null; + let sandboxRoot: string | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (token === "--mode") { + mode = argv[index + 1] ?? mode; + index += 1; + continue; + } + + if (token === "--cwd") { + cwd = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (token === "--sandbox-root") { + sandboxRoot = argv[index + 1] ?? null; + index += 1; + } + } + + return { mode, cwd, sandboxRoot }; +} + +function printProbeOutput(payload: Record) { + console.log(JSON.stringify(payload, null, 2)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + try { + const plan = buildSacrificialWorkspaceProjectionPlan({ + cwd: args.cwd ?? "", + sandboxRoot: args.sandboxRoot ?? "", + }); + + if (args.mode === "run-check") { + const workspaceExists = await stat(plan.workspacePath) + .then(() => true) + .catch(() => false); + + if (!workspaceExists) { + printProbeOutput({ + status: "blocked", + mode: "run-check", + cwd: plan.cwd, + sandboxRoot: plan.sandboxRoot, + workspacePath: plan.workspacePath, + lane: plan.lane, + command: plan.command, + reason: "projected-workspace-missing", + detail: + "The projected sandbox workspace path does not exist, and this lane forbids install/materialization into the sandbox.", + allowHostGlobalPnpmFallback: plan.allowHostGlobalPnpmFallback, + allowInstallOrMaterialization: plan.allowInstallOrMaterialization, + }); + return; + } + } + + printProbeOutput({ + status: "ready", + mode: args.mode, + cwd: plan.cwd, + sandboxRoot: plan.sandboxRoot, + workspacePath: plan.workspacePath, + lane: plan.lane, + command: plan.command, + allowHostGlobalPnpmFallback: plan.allowHostGlobalPnpmFallback, + allowInstallOrMaterialization: plan.allowInstallOrMaterialization, + }); + } catch (error) { + printProbeOutput({ + status: "blocked", + cwd: args.cwd, + sandboxRoot: args.sandboxRoot, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +await main(); diff --git a/src/assets/AppIcon.icns b/src/assets/AppIcon.icns new file mode 100644 index 0000000..0be1103 Binary files /dev/null and b/src/assets/AppIcon.icns differ diff --git a/src/assets/app-icon.png b/src/assets/app-icon.png new file mode 100644 index 0000000..ed2b65c Binary files /dev/null and b/src/assets/app-icon.png differ diff --git a/src/bun/codex-app-server-client.test.ts b/src/bun/codex-app-server-client.test.ts new file mode 100644 index 0000000..eff17e8 --- /dev/null +++ b/src/bun/codex-app-server-client.test.ts @@ -0,0 +1,163 @@ +import { PassThrough } from "node:stream"; + +import { describe, expect, test } from "bun:test"; + +import { + createCodexAppServerTransportFromChild, + inspectCodexRuntimeActivity, + listThreadsForCwdViaCodexAppServer, +} from "./codex-app-server-client"; + +function createMemoryTransport(messages: unknown[], sent: unknown[] = []) { + return { + sent, + async readMessage() { + return messages.shift(); + }, + async writeMessage(message: unknown) { + sent.push(message); + }, + async close() {}, + }; +} + +describe("createCodexAppServerTransportFromChild", () => { + test("writes newline-delimited JSON, reads one parsed line, and closes the child", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const written: string[] = []; + let killed = false; + + stdin.on("data", (chunk) => { + written.push(chunk.toString("utf8")); + }); + + const transport = createCodexAppServerTransportFromChild({ + stdin, + stdout, + kill() { + killed = true; + }, + }); + + await transport.writeMessage({ id: 9, method: "ping", params: { ok: true } }); + stdout.write(`${JSON.stringify({ id: 9, result: { pong: true } })}\n`); + + await expect(transport.readMessage()).resolves.toEqual({ + id: 9, + result: { pong: true }, + }); + expect(written).toEqual(['{"id":9,"method":"ping","params":{"ok":true}}\n']); + + await transport.close(); + + expect(killed).toBe(true); + }); + + test("fails closed and kills the child when stdout is missing during setup", () => { + const stdin = new PassThrough(); + let killed = false; + + expect(() => + createCodexAppServerTransportFromChild({ + stdin, + stdout: undefined as never, + kill() { + killed = true; + }, + }), + ).toThrow(); + + expect(killed).toBe(true); + }); +}); + +describe("listThreadsForCwdViaCodexAppServer", () => { + test("parses canonical discovery records from thread/list", async () => { + const sent: unknown[] = []; + const messages = [ + { id: 1, result: { serverInfo: { name: "codex-app-server" } } }, + { + id: 2, + result: { + data: [ + { + id: "thr_123", + name: "Fix hook lifecycle", + cwd: "/tmp/project", + }, + { + id: "thr_999", + name: null, + cwd: "/tmp/project", + }, + ], + }, + }, + ]; + + const result = await listThreadsForCwdViaCodexAppServer( + createMemoryTransport(messages, sent), + "/tmp/project", + ); + + expect(result).toEqual([ + { + threadId: "thr_123", + threadName: "Fix hook lifecycle", + cwd: "/tmp/project", + }, + { + threadId: "thr_999", + threadName: null, + cwd: "/tmp/project", + }, + ]); + }); +}); + +describe("inspectCodexRuntimeActivity", () => { + test("reports idle when no threads are loaded", async () => { + const sent: unknown[] = []; + const messages = [ + { id: 1, result: { serverInfo: { name: "codex-app-server" } } }, + { id: 2, result: { data: [] } }, + ]; + + const result = await inspectCodexRuntimeActivity(createMemoryTransport(messages, sent)); + + expect(result).toEqual({ + status: "idle", + loadedThreadIds: [], + activeThreadIds: [], + reason: null, + }); + }); + + test("reports active when any loaded thread has active runtime status", async () => { + const sent: unknown[] = []; + const messages = [ + { id: 1, result: { serverInfo: { name: "codex-app-server" } } }, + { id: 2, result: { data: ["thr_idle", "thr_active"] } }, + { id: 3, result: { thread: { id: "thr_idle", status: { type: "idle" } } } }, + { + id: 4, + result: { + thread: { + id: "thr_active", + status: { type: "active", activeFlags: ["waitingOnApproval"] }, + }, + }, + }, + ]; + + const result = await inspectCodexRuntimeActivity(createMemoryTransport(messages, sent)); + + expect(result).toEqual({ + status: "active", + loadedThreadIds: ["thr_idle", "thr_active"], + activeThreadIds: ["thr_active"], + reason: "active-thread-status", + }); + }); +}); diff --git a/src/bun/codex-app-server-client.ts b/src/bun/codex-app-server-client.ts new file mode 100644 index 0000000..fc14e16 --- /dev/null +++ b/src/bun/codex-app-server-client.ts @@ -0,0 +1,285 @@ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { Readable, Writable } from "node:stream"; + +export type CodexAppServerTransport = { + sent: unknown[]; + readMessage: () => Promise; + writeMessage: (message: unknown) => Promise; + close: () => Promise; +}; + +export type CanonicalThreadDiscoveryRecord = { + threadId: string; + threadName: string | null; + cwd: string | null; +}; + +export type CodexRuntimeActivityInspection = { + status: "idle" | "active" | "unknown"; + loadedThreadIds: string[]; + activeThreadIds: string[]; + reason: string | null; +}; + +export type CodexAppServerNotification = { + method: string; + params?: unknown; +}; + +type LocalCodexAppServerChild = { + stdin: Writable; + stdout: Readable; + kill: () => void; +}; + +function failClosedTransportStartup( + child: Pick, + message: string, +): never { + child.kill(); + throw new Error(message); +} + +function createReadMessage(lines: string[]) { + return async () => { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const line = lines.shift(); + if (line) { + return JSON.parse(line); + } + await Bun.sleep(25); + } + throw new Error("app-server-timeout"); + }; +} + +export function createCodexAppServerTransportFromChild( + child: LocalCodexAppServerChild, +): CodexAppServerTransport { + if (!child.stdin) { + failClosedTransportStartup(child, "app-server-stdin-missing"); + } + if (!child.stdout) { + failClosedTransportStartup(child, "app-server-stdout-missing"); + } + + const sent: unknown[] = []; + const lines: string[] = []; + let readline; + try { + readline = createInterface({ input: child.stdout }); + } catch { + failClosedTransportStartup(child, "app-server-transport-setup-failed"); + } + readline.on("line", (line) => lines.push(line)); + + return { + sent, + readMessage: createReadMessage(lines), + async writeMessage(message) { + sent.push(message); + child.stdin.write(`${JSON.stringify(message)}\n`); + }, + async close() { + readline.close(); + child.kill(); + }, + }; +} + +export async function createSpawnedCodexAppServerTransport(): Promise { + const child = spawn("codex", ["app-server"], { + stdio: ["pipe", "pipe", "ignore"], + }); + + if (!child.stdin || !child.stdout) { + child.kill(); + throw new Error("app-server-transport-setup-failed"); + } + + return createCodexAppServerTransportFromChild({ + stdin: child.stdin, + stdout: child.stdout, + kill() { + child.kill(); + }, + }); +} + +async function rpcCall( + transport: CodexAppServerTransport, + id: number, + method: string, + params: Record, + onNotification?: (notification: CodexAppServerNotification) => void | Promise, +) { + await transport.writeMessage({ id, method, params }); + for (let attempt = 0; attempt < 50; attempt += 1) { + const message = (await transport.readMessage()) as + | { + id?: number; + method?: string; + params?: unknown; + result?: Record; + error?: { message?: string }; + } + | undefined; + if (typeof message?.method === "string") { + await onNotification?.({ method: message.method, params: message.params }); + continue; + } + if (message?.id === id) { + return message as + | { + id: number; + result?: Record; + error?: { message?: string }; + } + | undefined; + } + } + + throw new Error(`app-server-response-timeout:${method}`); +} + +async function initializeCodexAppServerConnection(transport: CodexAppServerTransport) { + const init = await rpcCall(transport, 1, "initialize", { + clientInfo: { + name: "loopndroll", + title: "Loopndroll", + version: "1.1.5", + }, + }); + if (!init?.result) { + return false; + } + + await transport.writeMessage({ method: "initialized", params: {} }); + return true; +} + +function getThreadStatusType(thread: unknown) { + if (typeof thread !== "object" || thread === null || !("status" in thread)) { + return null; + } + + const status = thread.status; + return typeof status === "object" && + status !== null && + "type" in status && + typeof status.type === "string" + ? status.type + : null; +} + +export async function inspectCodexRuntimeActivity( + transport: CodexAppServerTransport, +): Promise { + try { + if (!(await initializeCodexAppServerConnection(transport))) { + return { + status: "unknown", + loadedThreadIds: [], + activeThreadIds: [], + reason: "initialize-failed", + }; + } + + const listed = await rpcCall(transport, 2, "thread/loaded/list", {}); + const loadedThreadIds = Array.isArray(listed?.result?.data) + ? listed.result.data.filter((threadId): threadId is string => typeof threadId === "string") + : []; + + if (!listed?.result || loadedThreadIds.length === 0) { + return { + status: listed?.result ? "idle" : "unknown", + loadedThreadIds, + activeThreadIds: [], + reason: listed?.result ? null : (listed?.error?.message ?? "loaded-list-failed"), + }; + } + + const activeThreadIds: string[] = []; + for (const [index, threadId] of loadedThreadIds.entries()) { + const read = await rpcCall(transport, 3 + index, "thread/read", { + threadId, + includeTurns: false, + }); + const statusType = getThreadStatusType(read?.result?.thread); + if (statusType === "active") { + activeThreadIds.push(threadId); + } + if (!read?.result || statusType === null) { + return { + status: "unknown", + loadedThreadIds, + activeThreadIds, + reason: read?.error?.message ?? "thread-status-unknown", + }; + } + } + + return { + status: activeThreadIds.length > 0 ? "active" : "idle", + loadedThreadIds, + activeThreadIds, + reason: activeThreadIds.length > 0 ? "active-thread-status" : null, + }; + } catch (error) { + return { + status: "unknown", + loadedThreadIds: [], + activeThreadIds: [], + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function listThreadsForCwdViaCodexAppServer( + transport: CodexAppServerTransport, + cwd: string, +): Promise { + const trimmedCwd = cwd.trim(); + if (trimmedCwd.length === 0) { + return []; + } + + if (!(await initializeCodexAppServerConnection(transport))) { + return []; + } + + const listed = await rpcCall(transport, 2, "thread/list", { + cwd: trimmedCwd, + }); + + const rows = Array.isArray(listed?.result?.data) ? listed.result.data : []; + return rows.flatMap((row) => { + if (typeof row !== "object" || row === null || !("id" in row) || typeof row.id !== "string") { + return []; + } + + const threadName = + "name" in row && typeof row.name === "string" + ? row.name + : "name" in row && row.name === null + ? null + : null; + + const resolvedCwd = + "cwd" in row && typeof row.cwd === "string" + ? row.cwd + : "cwd" in row && row.cwd === null + ? null + : trimmedCwd; + + return [ + { + threadId: row.id, + threadName, + cwd: resolvedCwd, + } satisfies CanonicalThreadDiscoveryRecord, + ]; + }); +} diff --git a/src/bun/constants.ts b/src/bun/constants.ts index 20febe0..4644926 100644 --- a/src/bun/constants.ts +++ b/src/bun/constants.ts @@ -1,4 +1,14 @@ -import type { LoopNotification, LoopPreset, LoopScope, LoopSession } from "../shared/app-rpc"; +import type { + LoopNotification, + LoopPreset, + LoopScope, + LoopSession, + HookLifecycleAppliedAction, + HookLifecycleDeferredAction, + HookLifecycleRequestedAction, + HookLifecycleRisk, + LoopndrollRuntimeState, +} from "../shared/app-rpc"; export const DEFAULT_PROMPT = "Keep working on the task. Do not finish yet."; @@ -23,3 +33,38 @@ export const NOTIFICATION_CHANNEL_VALUES = [ "slack", "telegram", ] as const satisfies readonly LoopNotification["channel"][]; + +export const LOOPNDROLL_RUNTIME_STATE_VALUES = [ + "running", + "paused", + "stopped", +] as const satisfies readonly LoopndrollRuntimeState[]; + +export const HOOK_LIFECYCLE_REQUESTED_ACTION_VALUES = [ + "none", + "pause", + "resume", + "start", + "stop", + "clear-managed-hook", +] as const satisfies readonly HookLifecycleRequestedAction[]; + +export const HOOK_LIFECYCLE_APPLIED_ACTION_VALUES = [ + "none", + "running", + "soft-pause", + "full-removal", + "full-removal-deferred", +] as const satisfies readonly HookLifecycleAppliedAction[]; + +export const HOOK_LIFECYCLE_DEFERRED_ACTION_VALUES = [ + "none", + "remove-managed-hooks-and-unload-runtime", +] as const satisfies readonly HookLifecycleDeferredAction[]; + +export const HOOK_LIFECYCLE_RISK_VALUES = [ + "none", + "active-processes-detected", + "activity-unknown", + "runtime-unload-unproven", +] as const satisfies readonly HookLifecycleRisk[]; diff --git a/src/bun/db/hook-lifecycle-migrations.test.ts b/src/bun/db/hook-lifecycle-migrations.test.ts new file mode 100644 index 0000000..7bc4392 --- /dev/null +++ b/src/bun/db/hook-lifecycle-migrations.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; + +import { hookLifecycleMigrations } from "./hook-lifecycle-migrations"; +import { applyAppMigrations } from "./migrations"; + +function applyParkPassiveMigration(db: Database) { + const migration = hookLifecycleMigrations.find( + (candidate) => candidate.name === "park_passive_v1", + ); + if (!migration) { + throw new Error("park_passive_v1 migration is missing"); + } + + for (const statement of migration.statements) { + db.exec(statement); + } +} + +function createPassiveMigrationSchema(db: Database) { + db.exec(` + create table settings ( + id integer primary key, + global_preset text + ); + + create table sessions ( + thread_id text primary key, + preset text, + preset_overridden integer not null default 0, + active_since text + ); + + create table session_remote_prompts ( + thread_id text not null, + delivery_mode text not null, + prompt_text text not null, + primary key(thread_id, delivery_mode) + ); + `); +} + +function markAppliedThrough(db: Database, migrationId: number) { + db.exec(` + create table schema_migrations ( + id integer primary key, + name text not null, + applied_at text not null + ); + `); + + const insertMigration = db.query( + "insert into schema_migrations (id, name, applied_at) values (?, ?, ?)", + ); + for (let id = 1; id <= migrationId; id += 1) { + insertMigration.run(id, `existing_${id}`, "2026-04-24T10:00:00.000Z"); + } +} + +function createPreCompletionCheckConstraintSchema(db: Database) { + db.exec(` + create table settings ( + id integer primary key, + default_prompt text not null, + scope text not null, + global_preset text, + global_notification_id text, + global_completion_check_id text, + global_completion_check_wait_for_reply integer not null default 0, + hooks_auto_registration integer not null default 1, + check (id = 1) + ); + + create table notifications ( + id text primary key, + label text not null, + channel text not null, + webhook_url text, + chat_id text, + bot_token text, + bot_url text, + chat_username text, + chat_display_name text, + created_at text not null + ); + + create table sessions ( + session_id text primary key, + session_ref text not null, + source text not null, + cwd text, + archived integer not null default 0, + first_seen_at text not null, + last_seen_at text not null, + active_since text, + stop_count integer not null default 0, + preset text, + preset_overridden integer not null default 0, + completion_check_id text, + completion_check_wait_for_reply integer not null default 0, + title text, + transcript_path text, + last_assistant_message text + ); + + create table session_notifications ( + session_id text not null, + notification_id text not null, + primary key (session_id, notification_id) + ); + + create table session_runtime ( + session_id text primary key, + remaining_turns integer not null + ); + + create table session_remote_prompts ( + session_id text not null, + source text not null, + delivery_mode text not null default 'once', + prompt_text text not null, + telegram_chat_id text, + telegram_message_id integer, + created_at text not null, + primary key (session_id, delivery_mode) + ); + + create table telegram_delivery_receipts ( + id text primary key, + notification_id text, + session_id text not null, + bot_token text not null, + chat_id text not null, + telegram_message_id integer not null, + created_at text not null + ); + + create table session_awaiting_replies ( + session_id text not null, + bot_token text not null, + chat_id text not null, + turn_id text, + started_at text not null, + primary key (session_id, bot_token, chat_id) + ); + `); + markAppliedThrough(db, 14); +} + +function createPreAwaitReplyConstraintSchema(db: Database) { + db.exec(` + create table settings ( + id integer primary key, + default_prompt text not null, + scope text not null, + global_preset text, + global_notification_id text, + hooks_auto_registration integer not null default 1, + check (id = 1) + ); + + create table notifications ( + id text primary key, + label text not null, + channel text not null, + webhook_url text, + chat_id text, + bot_token text, + bot_url text, + chat_username text, + chat_display_name text, + created_at text not null + ); + + create table sessions ( + session_id text primary key, + session_ref text, + source text not null, + cwd text, + first_seen_at text not null, + last_seen_at text not null, + active_since text, + stop_count integer not null default 0, + preset text, + title text, + transcript_path text, + last_assistant_message text + ); + + create table session_notifications ( + session_id text not null, + notification_id text not null, + primary key (session_id, notification_id) + ); + + create table session_runtime ( + session_id text primary key, + remaining_turns integer not null + ); + + create table session_remote_prompts ( + session_id text primary key, + source text not null, + prompt_text text not null, + telegram_chat_id text, + telegram_message_id integer, + created_at text not null + ); + + create table telegram_delivery_receipts ( + id text primary key, + notification_id text, + session_id text not null, + bot_token text not null, + chat_id text not null, + telegram_message_id integer not null, + created_at text not null + ); + + create table session_ref_sequence ( + id integer primary key check (id = 1), + last_value integer not null + ); + `); + markAppliedThrough(db, 5); +} + +function testParkPassiveMigration() { + const db = new Database(":memory:"); + createPassiveMigrationSchema(db); + db.query("insert into settings (id, global_preset) values (1, 'passive')").run(); + db.query( + `insert into sessions (thread_id, preset, preset_overridden, active_since) values + ('explicit_passive', 'passive', 1, '2026-04-24T10:00:00.000Z'), + ('inherited_passive', null, 0, '2026-04-24T10:00:00.000Z'), + ('explicit_await', 'await-reply', 1, '2026-04-24T10:00:00.000Z')`, + ).run(); + db.query( + `insert into session_remote_prompts (thread_id, delivery_mode, prompt_text) values + ('explicit_passive', 'once', 'stale explicit passive prompt'), + ('inherited_passive', 'once', 'stale inherited passive prompt'), + ('explicit_await', 'once', 'valid await prompt')`, + ).run(); + + applyParkPassiveMigration(db); + + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect( + db + .query( + "select preset, preset_overridden, active_since from sessions where thread_id = 'explicit_passive'", + ) + .get(), + ).toEqual({ + preset: null, + preset_overridden: 1, + active_since: null, + }); + expect( + db + .query( + "select preset, preset_overridden, active_since from sessions where thread_id = 'inherited_passive'", + ) + .get(), + ).toEqual({ + preset: null, + preset_overridden: 0, + active_since: null, + }); + expect( + db.query("select thread_id, prompt_text from session_remote_prompts order by thread_id").all(), + ).toEqual([{ thread_id: "explicit_await", prompt_text: "valid await prompt" }]); +} + +function testParkPassiveBeforeConstrainedRebuilds() { + const db = new Database(":memory:"); + createPreCompletionCheckConstraintSchema(db); + db.query( + `insert into settings ( + id, + default_prompt, + scope, + global_preset, + hooks_auto_registration + ) values (1, 'Keep working', 'global', 'passive', 1)`, + ).run(); + db.query( + `insert into sessions ( + session_id, + session_ref, + source, + cwd, + archived, + first_seen_at, + last_seen_at, + active_since, + stop_count, + preset, + preset_overridden + ) values + ('explicit_passive', 'C1', 'stop', '/tmp/project', 0, '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, 'passive', 1), + ('inherited_passive', 'C2', 'stop', '/tmp/project', 0, '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, null, 0), + ('explicit_await', 'C3', 'stop', '/tmp/project', 0, '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, 'await-reply', 1)`, + ).run(); + db.query( + `insert into session_remote_prompts ( + session_id, + source, + delivery_mode, + prompt_text, + created_at + ) values + ('explicit_passive', 'telegram', 'once', 'stale explicit passive prompt', '2026-04-24T10:00:00.000Z'), + ('inherited_passive', 'telegram', 'once', 'stale inherited passive prompt', '2026-04-24T10:00:00.000Z'), + ('explicit_await', 'telegram', 'once', 'valid await prompt', '2026-04-24T10:00:00.000Z')`, + ).run(); + + applyAppMigrations(db); + + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect( + db + .query( + `select thread_id, preset, preset_overridden, active_since + from sessions + order by thread_id`, + ) + .all(), + ).toEqual([ + { + thread_id: "explicit_await", + preset: "await-reply", + preset_overridden: 1, + active_since: "2026-04-24T10:00:00.000Z", + }, + { + thread_id: "explicit_passive", + preset: null, + preset_overridden: 1, + active_since: null, + }, + { + thread_id: "inherited_passive", + preset: null, + preset_overridden: 0, + active_since: null, + }, + ]); + expect( + db.query("select thread_id, prompt_text from session_remote_prompts order by thread_id").all(), + ).toEqual([{ thread_id: "explicit_await", prompt_text: "valid await prompt" }]); +} + +function testParkPassiveBeforeFirstConstrainedRebuild() { + const db = new Database(":memory:"); + createPreAwaitReplyConstraintSchema(db); + db.query( + `insert into settings ( + id, + default_prompt, + scope, + global_preset, + hooks_auto_registration + ) values (1, 'Keep working', 'global', 'passive', 1)`, + ).run(); + db.query( + `insert into sessions ( + session_id, + session_ref, + source, + cwd, + first_seen_at, + last_seen_at, + active_since, + stop_count, + preset + ) values + ('explicit_passive', 'C1', 'stop', '/tmp/project', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, 'passive'), + ('inherited_passive', 'C2', 'stop', '/tmp/project', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, null), + ('explicit_await', 'C3', 'stop', '/tmp/project', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', '2026-04-24T10:00:00.000Z', 1, 'await-reply')`, + ).run(); + db.query( + `insert into session_remote_prompts ( + session_id, + source, + prompt_text, + created_at + ) values + ('explicit_passive', 'telegram', 'stale explicit passive prompt', '2026-04-24T10:00:00.000Z'), + ('inherited_passive', 'telegram', 'stale inherited passive prompt', '2026-04-24T10:00:00.000Z'), + ('explicit_await', 'telegram', 'valid await prompt', '2026-04-24T10:00:00.000Z')`, + ).run(); + + applyAppMigrations(db); + + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect(db.query("select thread_id, preset from sessions where preset = 'passive'").all()).toEqual( + [], + ); + expect( + db.query("select thread_id, prompt_text from session_remote_prompts order by thread_id").all(), + ).toEqual([{ thread_id: "explicit_await", prompt_text: "valid await prompt" }]); +} + +describe("hook lifecycle migrations", () => { + test( + "parks explicit and inherited passive state without leaving stale Telegram prompts", + testParkPassiveMigration, + ); + test( + "parks passive state before constrained preset table rebuilds", + testParkPassiveBeforeConstrainedRebuilds, + ); + test( + "parks passive state before the first preset-constrained rebuild", + testParkPassiveBeforeFirstConstrainedRebuild, + ); +}); diff --git a/src/bun/db/hook-lifecycle-migrations.ts b/src/bun/db/hook-lifecycle-migrations.ts new file mode 100644 index 0000000..6a9cad3 --- /dev/null +++ b/src/bun/db/hook-lifecycle-migrations.ts @@ -0,0 +1,47 @@ +import type { AppMigration } from "./migrations"; + +export const hookLifecycleMigrations: AppMigration[] = [ + { + id: 20, + name: "hook_lifecycle_status", + statements: [ + `alter table settings add column hook_removal_pending integer not null default 0 check (hook_removal_pending in (0, 1))`, + `alter table settings add column hook_removal_next_attempt_at text`, + `alter table settings add column hook_lifecycle_status_json text`, + `update settings set hook_removal_pending = 0 where hook_removal_pending is null`, + ], + }, + { + id: 21, + name: "park_passive_v1", + statements: [ + `delete from session_remote_prompts + where thread_id in ( + select s.thread_id + from sessions s + left join settings st on st.id = 1 + where s.preset = 'passive' + or ( + s.preset is null + and s.preset_overridden = 0 + and st.global_preset = 'passive' + ) + )`, + `update sessions set preset = null, preset_overridden = 1, active_since = null where preset = 'passive'`, + `update sessions + set active_since = null + where preset is null + and preset_overridden = 0 + and (select global_preset from settings where id = 1) = 'passive'`, + `update settings set global_preset = null where global_preset = 'passive'`, + ], + }, + { + id: 22, + name: "telegram_mirror_mode", + statements: [ + `alter table settings add column mirror_enabled integer not null default 0 check (mirror_enabled in (0, 1))`, + `update settings set mirror_enabled = 0 where mirror_enabled is null`, + ], + }, +]; diff --git a/src/bun/db/migration-runtime.ts b/src/bun/db/migration-runtime.ts new file mode 100644 index 0000000..e1dcc37 --- /dev/null +++ b/src/bun/db/migration-runtime.ts @@ -0,0 +1,26 @@ +import type { Database } from "bun:sqlite"; + +function nowIsoString() { + return new Date().toISOString(); +} + +function shouldIgnoreMigrationStatementError(sqlite: Database, statement: string, error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (!message.toLowerCase().includes("duplicate column name:")) { + return false; + } + + const match = /^\s*alter\s+table\s+(\w+)\s+add\s+column\s+(\w+)/i.exec(statement); + if (!match) { + return false; + } + + const [, tableName, columnName] = match; + const rows = sqlite.query(`pragma table_info(${tableName})`).all() as Array<{ + name?: string; + }>; + + return rows.some((row) => row.name === columnName); +} + +export { nowIsoString, shouldIgnoreMigrationStatementError }; diff --git a/src/bun/db/migrations.ts b/src/bun/db/migrations.ts index c026b4c..ee461dd 100644 --- a/src/bun/db/migrations.ts +++ b/src/bun/db/migrations.ts @@ -2,19 +2,23 @@ import type { Database } from "bun:sqlite"; import { DEFAULT_PROMPT, LOOP_PRESET_VALUES, + LOOPNDROLL_RUNTIME_STATE_VALUES, LOOP_SCOPE_VALUES, LOOP_SESSION_SOURCE_VALUES, NOTIFICATION_CHANNEL_VALUES, } from "../constants"; +import { hookLifecycleMigrations } from "./hook-lifecycle-migrations"; +import { nowIsoString, shouldIgnoreMigrationStatementError } from "./migration-runtime"; +import { + PARK_PASSIVE_OVERRIDABLE_SESSION_ID_STATEMENTS, + PARK_PASSIVE_SESSION_ID_STATEMENTS, +} from "./passive-preset-migration-statements"; -export type AppMigration = { - id: number; - name: string; - statements: string[]; -}; +export type AppMigration = { id: number; name: string; statements: string[] }; const SETTINGS_SCOPE_CHECK = LOOP_SCOPE_VALUES.map((value) => `'${value}'`).join(", "); const PRESET_CHECK = LOOP_PRESET_VALUES.map((value) => `'${value}'`).join(", "); +const RUNTIME_STATE_CHECK = LOOPNDROLL_RUNTIME_STATE_VALUES.map((value) => `'${value}'`).join(", "); const SESSION_SOURCE_CHECK = LOOP_SESSION_SOURCE_VALUES.map((value) => `'${value}'`).join(", "); const NOTIFICATION_CHANNEL_CHECK = NOTIFICATION_CHANNEL_VALUES.map((value) => `'${value}'`).join( ", ", @@ -197,6 +201,7 @@ export const appMigrations: AppMigration[] = [ name: "await_reply_preset_and_waiters", statements: [ `pragma foreign_keys = off`, + ...PARK_PASSIVE_SESSION_ID_STATEMENTS, `alter table settings rename to settings_old`, `create table settings ( id integer primary key, @@ -496,6 +501,216 @@ export const appMigrations: AppMigration[] = [ name: "completion_checks_preset_constraints", statements: [ `pragma foreign_keys = off`, + ...PARK_PASSIVE_OVERRIDABLE_SESSION_ID_STATEMENTS, + `alter table settings rename to settings_old`, + `create table settings ( + id integer primary key, + default_prompt text not null, + scope text not null check (scope in (${SETTINGS_SCOPE_CHECK})), + global_preset text check (global_preset is null or global_preset in (${PRESET_CHECK})), + global_notification_id text, + global_completion_check_id text, + global_completion_check_wait_for_reply integer not null default 0 check (global_completion_check_wait_for_reply in (0, 1)), + hooks_auto_registration integer not null default 1 check (hooks_auto_registration in (0, 1)), + check (id = 1) + )`, + `insert into settings ( + id, + default_prompt, + scope, + global_preset, + global_notification_id, + global_completion_check_id, + global_completion_check_wait_for_reply, + hooks_auto_registration + ) + select + id, + default_prompt, + scope, + global_preset, + global_notification_id, + global_completion_check_id, + global_completion_check_wait_for_reply, + hooks_auto_registration + from settings_old`, + `drop table settings_old`, + `alter table sessions rename to sessions_old`, + `alter table session_notifications rename to session_notifications_old`, + `alter table session_runtime rename to session_runtime_old`, + `alter table session_remote_prompts rename to session_remote_prompts_old`, + `alter table telegram_delivery_receipts rename to telegram_delivery_receipts_old`, + `alter table session_awaiting_replies rename to session_awaiting_replies_old`, + `drop index if exists sessions_first_seen_at_idx`, + `drop index if exists sessions_last_seen_at_idx`, + `drop index if exists sessions_session_ref_idx`, + `drop index if exists session_notifications_notification_idx`, + `drop index if exists telegram_delivery_receipts_lookup_idx`, + `create table sessions ( + session_id text primary key, + session_ref text not null, + source text not null check (source in (${SESSION_SOURCE_CHECK})), + cwd text, + archived integer not null default 0 check (archived in (0, 1)), + first_seen_at text not null, + last_seen_at text not null, + active_since text, + stop_count integer not null default 0 check (stop_count >= 0), + preset text check (preset is null or preset in (${PRESET_CHECK})), + preset_overridden integer not null default 0 check (preset_overridden in (0, 1)), + completion_check_id text, + completion_check_wait_for_reply integer not null default 0 check (completion_check_wait_for_reply in (0, 1)), + title text, + transcript_path text, + last_assistant_message text + )`, + `insert into sessions ( + session_id, + session_ref, + source, + cwd, + archived, + first_seen_at, + last_seen_at, + active_since, + stop_count, + preset, + preset_overridden, + completion_check_id, + completion_check_wait_for_reply, + title, + transcript_path, + last_assistant_message + ) + select + session_id, + session_ref, + source, + cwd, + archived, + first_seen_at, + last_seen_at, + active_since, + stop_count, + preset, + preset_overridden, + completion_check_id, + completion_check_wait_for_reply, + title, + transcript_path, + last_assistant_message + from sessions_old`, + `create index sessions_first_seen_at_idx on sessions(first_seen_at, session_id)`, + `create index sessions_last_seen_at_idx on sessions(last_seen_at, session_id)`, + `create unique index sessions_session_ref_idx on sessions(session_ref)`, + `create table session_notifications ( + session_id text not null references sessions(session_id) on delete cascade, + notification_id text not null references notifications(id) on delete cascade, + primary key (session_id, notification_id) + )`, + `insert into session_notifications (session_id, notification_id) + select session_id, notification_id from session_notifications_old`, + `create index session_notifications_notification_idx on session_notifications(notification_id, session_id)`, + `create table session_runtime ( + session_id text primary key references sessions(session_id) on delete cascade, + remaining_turns integer not null check (remaining_turns >= 0) + )`, + `insert into session_runtime (session_id, remaining_turns) + select session_id, remaining_turns from session_runtime_old`, + `create table session_remote_prompts ( + session_id text not null references sessions(session_id) on delete cascade, + source text not null, + delivery_mode text not null default 'once', + prompt_text text not null, + telegram_chat_id text, + telegram_message_id integer, + created_at text not null, + primary key (session_id, delivery_mode) + )`, + `insert into session_remote_prompts ( + session_id, + source, + delivery_mode, + prompt_text, + telegram_chat_id, + telegram_message_id, + created_at + ) + select + session_id, + source, + delivery_mode, + prompt_text, + telegram_chat_id, + telegram_message_id, + created_at + from session_remote_prompts_old`, + `create table telegram_delivery_receipts ( + id text primary key, + notification_id text references notifications(id) on delete set null, + session_id text not null references sessions(session_id) on delete cascade, + bot_token text not null, + chat_id text not null, + telegram_message_id integer not null, + created_at text not null + )`, + `insert into telegram_delivery_receipts ( + id, + notification_id, + session_id, + bot_token, + chat_id, + telegram_message_id, + created_at + ) + select + id, + notification_id, + session_id, + bot_token, + chat_id, + telegram_message_id, + created_at + from telegram_delivery_receipts_old`, + `create unique index telegram_delivery_receipts_lookup_idx + on telegram_delivery_receipts(bot_token, chat_id, telegram_message_id)`, + `create table session_awaiting_replies ( + session_id text not null references sessions(session_id) on delete cascade, + bot_token text not null, + chat_id text not null, + turn_id text, + started_at text not null, + primary key (session_id, bot_token, chat_id) + )`, + `insert into session_awaiting_replies ( + session_id, + bot_token, + chat_id, + turn_id, + started_at + ) + select + session_id, + bot_token, + chat_id, + turn_id, + started_at + from session_awaiting_replies_old`, + `drop table session_awaiting_replies_old`, + `drop table telegram_delivery_receipts_old`, + `drop table session_remote_prompts_old`, + `drop table session_runtime_old`, + `drop table session_notifications_old`, + `drop table sessions_old`, + `pragma foreign_keys = on`, + ], + }, + { + id: 16, + name: "preset_constraints", + statements: [ + `pragma foreign_keys = off`, + ...PARK_PASSIVE_OVERRIDABLE_SESSION_ID_STATEMENTS, `alter table settings rename to settings_old`, `create table settings ( id integer primary key, @@ -699,35 +914,41 @@ export const appMigrations: AppMigration[] = [ `pragma foreign_keys = on`, ], }, + { + id: 17, + name: "runtime_state", + statements: [ + `alter table settings add column runtime_state text not null default 'running' check (runtime_state in (${RUNTIME_STATE_CHECK}))`, + `update settings set runtime_state = 'running' where runtime_state is null or trim(runtime_state) = ''`, + ], + }, + { + id: 18, + name: "canonical_thread_fields", + statements: [ + `alter table sessions rename column session_id to thread_id`, + `alter table sessions rename column title to thread_name`, + `alter table session_notifications rename column session_id to thread_id`, + `alter table session_runtime rename column session_id to thread_id`, + `alter table session_remote_prompts rename column session_id to thread_id`, + `alter table telegram_delivery_receipts rename column session_id to thread_id`, + `alter table session_awaiting_replies rename column session_id to thread_id`, + ], + }, + { + id: 19, + name: "orphaned_refresh_miss_count", + statements: [ + `alter table sessions add column orphaned_refresh_miss_count integer not null default 0 check (orphaned_refresh_miss_count >= 0)`, + `update sessions + set orphaned_refresh_miss_count = 0 + where orphaned_refresh_miss_count is null + or orphaned_refresh_miss_count < 0`, + ], + }, + ...hookLifecycleMigrations, ]; -function nowIsoString() { - return new Date().toISOString(); -} - -function shouldIgnoreMigrationStatementError( - sqlite: Database, - statement: string, - error: unknown, -) { - const message = error instanceof Error ? error.message : String(error); - if (!message.toLowerCase().includes("duplicate column name:")) { - return false; - } - - const match = /^\s*alter\s+table\s+(\w+)\s+add\s+column\s+(\w+)/i.exec(statement); - if (!match) { - return false; - } - - const [, tableName, columnName] = match; - const rows = sqlite.query(`pragma table_info(${tableName})`).all() as Array<{ - name?: string; - }>; - - return rows.some((row) => row.name === columnName); -} - export function applyAppMigrations( sqlite: Database, migrations: readonly AppMigration[] = appMigrations, diff --git a/src/bun/db/passive-preset-migration-statements.ts b/src/bun/db/passive-preset-migration-statements.ts new file mode 100644 index 0000000..6a7a27c --- /dev/null +++ b/src/bun/db/passive-preset-migration-statements.ts @@ -0,0 +1,38 @@ +export const PARK_PASSIVE_SESSION_ID_STATEMENTS = [ + `delete from session_remote_prompts + where session_id in ( + select s.session_id + from sessions s + left join settings st on st.id = 1 + where s.preset = 'passive' + or (s.preset is null and st.global_preset = 'passive') + )`, + `update sessions set preset = null, active_since = null where preset = 'passive'`, + `update sessions + set active_since = null + where preset is null + and (select global_preset from settings where id = 1) = 'passive'`, + `update settings set global_preset = null where global_preset = 'passive'`, +]; + +export const PARK_PASSIVE_OVERRIDABLE_SESSION_ID_STATEMENTS = [ + `delete from session_remote_prompts + where session_id in ( + select s.session_id + from sessions s + left join settings st on st.id = 1 + where s.preset = 'passive' + or ( + s.preset is null + and s.preset_overridden = 0 + and st.global_preset = 'passive' + ) + )`, + `update sessions set preset = null, preset_overridden = 1, active_since = null where preset = 'passive'`, + `update sessions + set active_since = null + where preset is null + and preset_overridden = 0 + and (select global_preset from settings where id = 1) = 'passive'`, + `update settings set global_preset = null where global_preset = 'passive'`, +]; diff --git a/src/bun/db/schema.ts b/src/bun/db/schema.ts index e33e4ba..4cf8532 100644 --- a/src/bun/db/schema.ts +++ b/src/bun/db/schema.ts @@ -1,10 +1,17 @@ import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import type { LoopNotification, LoopPreset, LoopScope, LoopSession } from "../../shared/app-rpc"; +import type { + LoopNotification, + LoopPreset, + LoopScope, + LoopSession, + LoopndrollRuntimeState, +} from "../../shared/app-rpc"; export const settings = sqliteTable("settings", { id: integer("id").primaryKey(), defaultPrompt: text("default_prompt").notNull(), scope: text("scope").$type().notNull(), + runtimeState: text("runtime_state").$type().notNull().default("running"), globalPreset: text("global_preset").$type(), globalNotificationId: text("global_notification_id"), globalCompletionCheckId: text("global_completion_check_id"), @@ -16,6 +23,10 @@ export const settings = sqliteTable("settings", { hooksAutoRegistration: integer("hooks_auto_registration", { mode: "boolean" }) .notNull() .default(true), + mirrorEnabled: integer("mirror_enabled", { mode: "boolean" }).notNull().default(false), + hookRemovalPending: integer("hook_removal_pending", { mode: "boolean" }).notNull().default(false), + hookRemovalNextAttemptAt: text("hook_removal_next_attempt_at"), + hookLifecycleStatusJson: text("hook_lifecycle_status_json").$type(), }); export const notifications = sqliteTable("notifications", { @@ -39,7 +50,7 @@ export const completionChecks = sqliteTable("completion_checks", { }); export const sessions = sqliteTable("sessions", { - sessionId: text("session_id").primaryKey(), + threadId: text("thread_id").primaryKey(), sessionRef: text("session_ref").notNull(), source: text("source").$type().notNull(), cwd: text("cwd"), @@ -54,7 +65,8 @@ export const sessions = sqliteTable("sessions", { completionCheckWaitForReply: integer("completion_check_wait_for_reply", { mode: "boolean" }) .notNull() .default(false), - title: text("title"), + threadName: text("thread_name"), + orphanedRefreshMissCount: integer("orphaned_refresh_miss_count").notNull().default(0), transcriptPath: text("transcript_path"), lastAssistantMessage: text("last_assistant_message"), }); @@ -62,29 +74,29 @@ export const sessions = sqliteTable("sessions", { export const sessionNotifications = sqliteTable( "session_notifications", { - sessionId: text("session_id") + threadId: text("thread_id") .notNull() - .references(() => sessions.sessionId, { onDelete: "cascade" }), + .references(() => sessions.threadId, { onDelete: "cascade" }), notificationId: text("notification_id") .notNull() .references(() => notifications.id, { onDelete: "cascade" }), }, - (table) => [primaryKey({ columns: [table.sessionId, table.notificationId] })], + (table) => [primaryKey({ columns: [table.threadId, table.notificationId] })], ); export const sessionRuntime = sqliteTable("session_runtime", { - sessionId: text("session_id") + threadId: text("thread_id") .primaryKey() - .references(() => sessions.sessionId, { onDelete: "cascade" }), + .references(() => sessions.threadId, { onDelete: "cascade" }), remainingTurns: integer("remaining_turns").notNull(), }); export const sessionRemotePrompts = sqliteTable( "session_remote_prompts", { - sessionId: text("session_id") + threadId: text("thread_id") .notNull() - .references(() => sessions.sessionId, { onDelete: "cascade" }), + .references(() => sessions.threadId, { onDelete: "cascade" }), source: text("source").notNull(), deliveryMode: text("delivery_mode").notNull(), promptText: text("prompt_text").notNull(), @@ -92,7 +104,7 @@ export const sessionRemotePrompts = sqliteTable( telegramMessageId: integer("telegram_message_id"), createdAt: text("created_at").notNull(), }, - (table) => [primaryKey({ columns: [table.sessionId, table.deliveryMode] })], + (table) => [primaryKey({ columns: [table.threadId, table.deliveryMode] })], ); export const telegramDeliveryReceipts = sqliteTable("telegram_delivery_receipts", { @@ -100,9 +112,9 @@ export const telegramDeliveryReceipts = sqliteTable("telegram_delivery_receipts" notificationId: text("notification_id").references(() => notifications.id, { onDelete: "set null", }), - sessionId: text("session_id") + threadId: text("thread_id") .notNull() - .references(() => sessions.sessionId, { onDelete: "cascade" }), + .references(() => sessions.threadId, { onDelete: "cascade" }), botToken: text("bot_token").notNull(), chatId: text("chat_id").notNull(), telegramMessageId: integer("telegram_message_id").notNull(), @@ -136,13 +148,13 @@ export const sessionRefSequence = sqliteTable("session_ref_sequence", { export const sessionAwaitingReplies = sqliteTable( "session_awaiting_replies", { - sessionId: text("session_id") + threadId: text("thread_id") .notNull() - .references(() => sessions.sessionId, { onDelete: "cascade" }), + .references(() => sessions.threadId, { onDelete: "cascade" }), botToken: text("bot_token").notNull(), chatId: text("chat_id").notNull(), turnId: text("turn_id"), startedAt: text("started_at").notNull(), }, - (table) => [primaryKey({ columns: [table.sessionId, table.botToken, table.chatId] })], + (table) => [primaryKey({ columns: [table.threadId, table.botToken, table.chatId] })], ); diff --git a/src/bun/hook-management-product.test.ts b/src/bun/hook-management-product.test.ts new file mode 100644 index 0000000..f86bb2c --- /dev/null +++ b/src/bun/hook-management-product.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import type { LoopndrollSnapshot } from "../shared/app-rpc"; + +import { buildHookFileTargets, buildLoopndrollSetupSnapshot } from "./hook-management"; + +function createBaseSnapshot( + hookLifecycle: Partial = {}, +): LoopndrollSnapshot { + return { + defaultPrompt: "Keep going", + scope: "global", + runtimeState: "running", + globalPreset: null, + globalNotificationId: null, + globalCompletionCheckId: null, + globalCompletionCheckWaitForReply: false, + hooksAutoRegistration: true, + mirrorEnabled: false, + notifications: [], + completionChecks: [], + health: { + registered: true, + issues: [], + hookRemovalWatcher: { + active: false, + pid: null, + lockPath: "/tmp/hook-removal-watch.lock", + startedAt: null, + repoRoot: null, + hooksPath: null, + runtimeStatePath: null, + message: "watcher not running", + }, + }, + hookLifecycle: { + requestedAction: "none", + appliedAction: "none", + deferredAction: "none", + remainingRisk: "none", + nextAutomaticStep: null, + message: "No hook lifecycle action has been requested.", + pending: false, + checkedAt: null, + objectives: { + inertNow: false, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + ...hookLifecycle, + }, + sessions: [], + }; +} + +describe("buildLoopndrollSetupSnapshot", () => { + test("returns a product-facing setup snapshot with runtime state and hook health", () => { + const baseSnapshot = createBaseSnapshot(); + + const snapshot = buildLoopndrollSetupSnapshot(baseSnapshot, { + registered: false, + issues: ["Managed Stop hook is not registered."], + hookRemovalWatcher: baseSnapshot.health.hookRemovalWatcher, + }); + + expect(snapshot.runtimeState).toBe("running"); + expect(snapshot.hooksAutoRegistration).toBe(true); + expect(snapshot.health.registered).toBe(false); + expect(snapshot.health.issues).toEqual(["Managed Stop hook is not registered."]); + expect(snapshot.health.hookRemovalWatcher.active).toBe(false); + }); + + test("does not let stale lifecycle copy override hook health", () => { + const baseSnapshot = createBaseSnapshot({ + requestedAction: "start", + appliedAction: "running", + message: "Loopndroll hooks registered and running.", + }); + + const snapshot = buildLoopndrollSetupSnapshot(baseSnapshot, { + registered: false, + issues: ["Managed Stop hook is not registered."], + hookRemovalWatcher: baseSnapshot.health.hookRemovalWatcher, + }); + + expect(snapshot.health.registered).toBe(false); + expect(snapshot.health.issues).toContain("Managed Stop hook is not registered."); + expect(snapshot.hookLifecycle.message).toBe("Loopndroll hooks registered and running."); + }); +}); + +describe("buildHookFileTargets", () => { + test("tracks global and repo-local hooks files without collapsing them", () => { + expect( + buildHookFileTargets("/home/me/.codex/hooks.json", [ + "/work/repo-a", + "/work/repo-b", + "/work/repo-a", + " ", + ]), + ).toEqual([ + { + path: "/home/me/.codex/hooks.json", + scope: "global", + }, + { + path: "/work/repo-a/.codex/hooks.json", + scope: "repo-local", + }, + { + path: "/work/repo-b/.codex/hooks.json", + scope: "repo-local", + }, + ]); + }); +}); diff --git a/src/bun/hook-management.ts b/src/bun/hook-management.ts index 198be46..340688f 100644 --- a/src/bun/hook-management.ts +++ b/src/bun/hook-management.ts @@ -1,9 +1,26 @@ import { spawn } from "node:child_process"; import { chmod, copyFile, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Database } from "bun:sqlite"; import { eq } from "drizzle-orm"; -import type { LoopndrollSnapshot } from "../shared/app-rpc"; +import type { + HookLifecycleRequestedAction, + HookLifecycleStatus, + LoopndrollSnapshot, +} from "../shared/app-rpc"; +import { + createSpawnedCodexAppServerTransport, + inspectCodexRuntimeActivity, + type CodexRuntimeActivityInspection, +} from "./codex-app-server-client"; import { getLoopndrollDatabase } from "./db/client"; import { settings } from "./db/schema"; +import { + acquireHookRemovalWatchLock, + getHookRemovalWatcherStatus, + releaseHookRemovalWatchLock, + releaseHookRemovalWatchLockSync, +} from "./hook-removal-watch-lock"; import { buildManagedHookScript } from "./managed-hook-script"; import { type HookMatcherGroup, @@ -18,12 +35,56 @@ import { ensureDirectory, getLoopndrollPaths, getSettingsRow, + normalizeLoopndrollRuntimeState, + nowIsoString, readSnapshotFromDatabase, } from "./loopndroll-core"; +import { refreshCanonicalThreadNames } from "./thread-name-refresh"; + +const PENDING_HOOK_REMOVAL_RECHECK_MS = 30_000; +const PENDING_HOOK_REMOVAL_RECHECK_MAX_MS = 5 * 60_000; +const PENDING_HOOK_REMOVAL_JITTER_MS = 2_000; + +type HookFileTarget = { + path: string; + scope: "global" | "repo-local"; +}; + +type ManagedHookRemovalResult = { + inspectedPaths: string[]; + changedPaths: string[]; + managedHookCountBefore: number; +}; + +export function buildHookFileTargets( + codexHooksPath: string, + repoCwds: readonly string[], +): HookFileTarget[] { + const targets = new Map(); + targets.set(codexHooksPath, { + path: codexHooksPath, + scope: "global", + }); -async function loadHooksDocument(paths: LoopndrollPaths) { + for (const repoCwd of repoCwds) { + const cwd = repoCwd.trim(); + if (cwd.length === 0) { + continue; + } + + const hookPath = join(cwd, ".codex", "hooks.json"); + targets.set(hookPath, { + path: hookPath, + scope: "repo-local", + }); + } + + return [...targets.values()]; +} + +async function loadHooksDocumentAtPath(path: string) { try { - const raw = await readFile(paths.codexHooksPath, "utf8"); + const raw = await readFile(path, "utf8"); return JSON.parse(raw) as HooksDocument; } catch (error) { if ( @@ -35,9 +96,9 @@ async function loadHooksDocument(paths: LoopndrollPaths) { return { hooks: {} }; } - const backupPath = `${paths.codexHooksPath}.corrupt.${Date.now()}`; + const backupPath = `${path}.corrupt.${Date.now()}`; try { - await copyFile(paths.codexHooksPath, backupPath); + await copyFile(path, backupPath); } catch { // Ignore backup failures and continue with a clean hooks file. } @@ -46,6 +107,10 @@ async function loadHooksDocument(paths: LoopndrollPaths) { } } +async function loadHooksDocument(paths: LoopndrollPaths) { + return loadHooksDocumentAtPath(paths.codexHooksPath); +} + function ensureCodexHooksFeature(configText: string) { const hasTrailingNewline = configText.endsWith("\n"); const lines = configText.split("\n"); @@ -101,6 +166,17 @@ function isManagedHookCommand(command: string | undefined) { return typeof command === "string" && command.includes(MANAGED_HOOK_MARKER); } +function countManagedHooks(hooksDocument: HooksDocument) { + let count = 0; + for (const groups of Object.values(hooksDocument.hooks ?? {})) { + for (const group of groups) { + count += (group.hooks ?? []).filter((hook) => isManagedHookCommand(hook.command)).length; + } + } + + return count; +} + function removeManagedHooks(hooksDocument: HooksDocument) { const nextHooks: Record = {}; @@ -124,6 +200,21 @@ function removeManagedHooks(hooksDocument: HooksDocument) { hooksDocument.hooks = nextHooks; } +function getHookFileTargets(paths: LoopndrollPaths, sqlite: Database): HookFileTarget[] { + const rows = sqlite + .query("select distinct cwd from sessions where cwd is not null and trim(cwd) != ''") + .all() as Array<{ cwd: string }>; + return buildHookFileTargets( + paths.codexHooksPath, + rows.flatMap((row) => (typeof row.cwd === "string" ? [row.cwd] : [])), + ); +} + +async function writeHooksDocument(path: string, hooksDocument: HooksDocument) { + await ensureDirectory(join(path, "..")); + await writeFile(path, `${JSON.stringify(hooksDocument, null, 2)}\n`, "utf8"); +} + function upsertManagedHooks(paths: LoopndrollPaths, hooksDocument: HooksDocument) { if (!hooksDocument.hooks) { hooksDocument.hooks = {}; @@ -190,40 +281,83 @@ async function ensureManagedHookScript(paths: LoopndrollPaths) { async function computeHealth(paths: LoopndrollPaths) { const issues: string[] = []; const configContents = await readFile(paths.codexConfigPath, "utf8").catch(() => null); - const hooksDocument = await loadHooksDocument(paths); + const { client } = getLoopndrollDatabase(paths.databasePath); + const hookTargets = getHookFileTargets(paths, client); + const runtimeState = normalizeLoopndrollRuntimeState(getSettingsRow().runtimeState); const scriptExists = await stat(paths.managedHookPath) .then(() => true) .catch(() => false); - const hookEvents = hooksDocument.hooks ?? {}; - const hasManagedSessionStart = (hookEvents.SessionStart ?? []).some((group) => - (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), - ); - const hasManagedStop = (hookEvents.Stop ?? []).some((group) => - (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), - ); - const hasManagedUserPromptSubmit = (hookEvents.UserPromptSubmit ?? []).some((group) => - (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), - ); + let hasManagedSessionStart = false; + let hasManagedStop = false; + let hasManagedUserPromptSubmit = false; + const managedHookPaths: string[] = []; + + for (const target of hookTargets) { + const exists = await stat(target.path) + .then(() => true) + .catch(() => false); + if (!exists) { + continue; + } + + const hooksDocument = await loadHooksDocumentAtPath(target.path); + const hookEvents = hooksDocument.hooks ?? {}; + const targetHasSessionStart = (hookEvents.SessionStart ?? []).some((group) => + (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), + ); + const targetHasStop = (hookEvents.Stop ?? []).some((group) => + (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), + ); + const targetHasUserPromptSubmit = (hookEvents.UserPromptSubmit ?? []).some((group) => + (group.hooks ?? []).some((hook) => isManagedHookCommand(hook.command)), + ); + + if (targetHasSessionStart || targetHasStop || targetHasUserPromptSubmit) { + managedHookPaths.push(target.path); + } + + hasManagedSessionStart = hasManagedSessionStart || targetHasSessionStart; + hasManagedStop = hasManagedStop || targetHasStop; + hasManagedUserPromptSubmit = hasManagedUserPromptSubmit || targetHasUserPromptSubmit; + } + + if (runtimeState === "stopped" && managedHookPaths.length > 0) { + issues.push("Managed hook entries still exist after stopped state."); + } - if (!configContents || !/\bcodex_hooks\s*=\s*true\b/.test(configContents)) { + if ( + runtimeState !== "stopped" && + (!configContents || !/\bcodex_hooks\s*=\s*true\b/.test(configContents)) + ) { issues.push("Codex hooks are not enabled in ~/.codex/config.toml."); } - if (!hasManagedSessionStart) { + if (runtimeState !== "stopped" && !hasManagedSessionStart) { issues.push("Managed SessionStart hook is not registered."); } - if (!hasManagedStop) { + if (runtimeState !== "stopped" && !hasManagedStop) { issues.push("Managed Stop hook is not registered."); } - if (!hasManagedUserPromptSubmit) { + if (runtimeState !== "stopped" && !hasManagedUserPromptSubmit) { issues.push("Managed UserPromptSubmit hook is not registered."); } - if (!scriptExists) { + if (runtimeState !== "stopped" && !scriptExists) { issues.push("Managed hook executable is missing."); } + const allRequiredHooksRegistered = + hasManagedSessionStart && hasManagedStop && hasManagedUserPromptSubmit; + const codexHooksEnabled = + runtimeState === "stopped" || + Boolean(configContents && /\bcodex_hooks\s*=\s*true\b/.test(configContents)); + const fullyRegistered = + runtimeState === "stopped" + ? managedHookPaths.length > 0 + : allRequiredHooksRegistered && codexHooksEnabled && scriptExists; + return { - registered: issues.length === 0, + registered: fullyRegistered, issues, + hookRemovalWatcher: await getHookRemovalWatcherStatus(paths), }; } @@ -244,30 +378,352 @@ async function ensureRegistered(paths: LoopndrollPaths) { }); } +async function clearManagedHookRegistration( + paths: LoopndrollPaths, +): Promise { + const { client } = getLoopndrollDatabase(paths.databasePath); + const targets = getHookFileTargets(paths, client); + const inspectedPaths: string[] = []; + const changedPaths: string[] = []; + let managedHookCountBefore = 0; + + for (const target of targets) { + const exists = await stat(target.path) + .then(() => true) + .catch(() => false); + if (!exists && target.scope === "repo-local") { + continue; + } + + inspectedPaths.push(target.path); + const hooksDocument = await loadHooksDocumentAtPath(target.path); + const before = countManagedHooks(hooksDocument); + managedHookCountBefore += before; + removeManagedHooks(hooksDocument); + + if (before > 0 || target.scope === "global") { + await writeHooksDocument(target.path, hooksDocument); + changedPaths.push(target.path); + } + } + + return { + inspectedPaths, + changedPaths, + managedHookCountBefore, + }; +} + +function setRuntimeState(value: "running" | "paused" | "stopped") { + const { db } = getLoopndrollDatabase(getLoopndrollPaths().databasePath); + db.update(settings).set({ runtimeState: value }).where(eq(settings.id, 1)).run(); +} + +function buildHookLifecycleStatus( + status: Omit & { checkedAt?: string | null }, +): HookLifecycleStatus { + return { + ...status, + checkedAt: status.checkedAt ?? nowIsoString(), + }; +} + +function persistHookLifecycleStatus(status: HookLifecycleStatus) { + const { db } = getLoopndrollDatabase(getLoopndrollPaths().databasePath); + db.update(settings) + .set({ + hookLifecycleStatusJson: JSON.stringify(status), + hookRemovalPending: status.pending, + hookRemovalNextAttemptAt: status.pending + ? new Date(Date.now() + PENDING_HOOK_REMOVAL_RECHECK_MS).toISOString() + : null, + }) + .where(eq(settings.id, 1)) + .run(); +} + +function setHookLifecycleStatus(status: HookLifecycleStatus) { + persistHookLifecycleStatus(status); + return status; +} + +function buildSoftPauseStatus( + requestedAction: HookLifecycleRequestedAction, + inspection: CodexRuntimeActivityInspection, +): HookLifecycleStatus { + const remainingRisk = + inspection.status === "active" ? "active-processes-detected" : "activity-unknown"; + + return buildHookLifecycleStatus({ + requestedAction, + appliedAction: "soft-pause", + deferredAction: "remove-managed-hooks-and-unload-runtime", + remainingRisk, + nextAutomaticStep: + "Recheck Codex runtime activity; when idle, remove managed hooks. Live runtime unload is not claimed until Codex reloads hooks.", + message: + inspection.status === "active" + ? "soft pause applied because active processes were detected" + : "full removal deferred until system is idle", + pending: true, + objectives: { + inertNow: true, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + }); +} + +async function inspectRuntimeActivity(): Promise { + let transport = null; + try { + transport = await createSpawnedCodexAppServerTransport(); + return await inspectCodexRuntimeActivity(transport); + } catch (error) { + return { + status: "unknown", + loadedThreadIds: [], + activeThreadIds: [], + reason: error instanceof Error ? error.message : String(error), + }; + } finally { + await transport?.close().catch(() => {}); + } +} + +async function completeManagedHookRemoval( + requestedAction: HookLifecycleRequestedAction, + paths: LoopndrollPaths, +) { + const removal = await clearManagedHookRegistration(paths); + const status = buildHookLifecycleStatus({ + requestedAction, + appliedAction: "full-removal-deferred", + deferredAction: "none", + remainingRisk: "runtime-unload-unproven", + nextAutomaticStep: null, + message: + "managed hooks were removed from hooks.json; live Codex runtime unload is not proven until Codex reloads hooks", + pending: false, + objectives: { + inertNow: true, + removedFromHooksJson: true, + unloadedFromLiveRuntime: false, + }, + }); + + const { db } = getLoopndrollDatabase(paths.databasePath); + db.update(settings) + .set({ + hooksAutoRegistration: false, + runtimeState: "stopped", + }) + .where(eq(settings.id, 1)) + .run(); + + await appendHookDebugLog(paths, { + type: "setup", + action: "managed-hook-full-removal", + requestedAction, + inspectedPaths: removal.inspectedPaths, + changedPaths: removal.changedPaths, + managedHookCountBefore: removal.managedHookCountBefore, + runtimeUnloadProven: false, + }); + + return setHookLifecycleStatus(status); +} + +async function applyIntelligentManagedHookRemoval(requestedAction: HookLifecycleRequestedAction) { + const paths = getLoopndrollPaths(); + const inspection = await inspectRuntimeActivity(); + + if (inspection.status !== "idle") { + setRuntimeState("paused"); + const status = setHookLifecycleStatus(buildSoftPauseStatus(requestedAction, inspection)); + await appendHookDebugLog(paths, { + type: "setup", + action: "managed-hook-removal-deferred", + requestedAction, + inspection, + }); + return status; + } + + return completeManagedHookRemoval(requestedAction, paths); +} + +export async function completePendingHookRemovalIfSafe() { + const settingsRow = getSettingsRow(); + if (!settingsRow.hookRemovalPending) { + return false; + } + + const nextAttemptAt = settingsRow.hookRemovalNextAttemptAt + ? Date.parse(settingsRow.hookRemovalNextAttemptAt) + : 0; + if (Number.isFinite(nextAttemptAt) && nextAttemptAt > Date.now()) { + return false; + } + + await applyIntelligentManagedHookRemoval("stop"); + return true; +} + +let pendingHookRemovalMonitorStarted = false; +let pendingHookRemovalMonitorTimer: ReturnType | null = null; +let pendingHookRemovalMonitorBackoffMs = PENDING_HOOK_REMOVAL_RECHECK_MS; +let pendingHookRemovalMonitorCleanupRegistered = false; + +function withPendingHookRemovalJitter(delayMs: number) { + return delayMs + Math.floor(Math.random() * PENDING_HOOK_REMOVAL_JITTER_MS); +} + +function registerHookRemovalMonitorCleanup(paths: LoopndrollPaths) { + if (pendingHookRemovalMonitorCleanupRegistered) { + return; + } + + pendingHookRemovalMonitorCleanupRegistered = true; + const cleanupAndExit = () => { + releaseHookRemovalWatchLockSync(paths); + process.exit(0); + }; + process.once("SIGTERM", cleanupAndExit); + process.once("SIGINT", cleanupAndExit); + process.once("exit", () => { + releaseHookRemovalWatchLockSync(paths); + }); +} + +function schedulePendingHookRemovalMonitor(paths: LoopndrollPaths) { + pendingHookRemovalMonitorTimer = setTimeout(() => { + void runPendingHookRemovalMonitorTick(paths); + }, withPendingHookRemovalJitter(pendingHookRemovalMonitorBackoffMs)); +} + +async function runPendingHookRemovalMonitorTick(paths: LoopndrollPaths) { + try { + const didRun = await completePendingHookRemovalIfSafe(); + pendingHookRemovalMonitorBackoffMs = didRun + ? PENDING_HOOK_REMOVAL_RECHECK_MS + : Math.min( + Math.floor(pendingHookRemovalMonitorBackoffMs * 1.5), + PENDING_HOOK_REMOVAL_RECHECK_MAX_MS, + ); + } catch (error) { + pendingHookRemovalMonitorBackoffMs = Math.min( + pendingHookRemovalMonitorBackoffMs * 2, + PENDING_HOOK_REMOVAL_RECHECK_MAX_MS, + ); + await appendHookDebugLog(paths, { + type: "setup", + action: "pending-hook-removal-recheck-failed", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + if (pendingHookRemovalMonitorStarted) { + schedulePendingHookRemovalMonitor(paths); + } + } +} + +export async function startHookRemovalPendingMonitor() { + if (pendingHookRemovalMonitorStarted) { + return "watcher already running"; + } + + const paths = getLoopndrollPaths(); + const lock = await acquireHookRemovalWatchLock(paths); + if (lock.status === "already-running") { + console.log(lock.message); + return lock.message; + } + + pendingHookRemovalMonitorStarted = true; + registerHookRemovalMonitorCleanup(paths); + schedulePendingHookRemovalMonitor(paths); + return lock.message; +} + +export async function stopHookRemovalPendingMonitorForEmergency() { + if (pendingHookRemovalMonitorTimer) { + clearTimeout(pendingHookRemovalMonitorTimer); + pendingHookRemovalMonitorTimer = null; + } + pendingHookRemovalMonitorStarted = false; + return releaseHookRemovalWatchLock(getLoopndrollPaths()); +} + +export function buildLoopndrollSetupSnapshot( + baseSnapshot: Omit, + health: LoopndrollSnapshot["health"], +): LoopndrollSnapshot { + return { + ...baseSnapshot, + health, + }; +} + export async function loadSnapshot(paths: LoopndrollPaths) { getLoopndrollDatabase(paths.databasePath); const baseSnapshot = readSnapshotFromDatabase(); const health = await computeHealth(paths); - return { - ...baseSnapshot, - health, - } satisfies LoopndrollSnapshot; + return buildLoopndrollSetupSnapshot(baseSnapshot, health); } export async function ensureLoopndrollSetup() { const paths = getLoopndrollPaths(); - getLoopndrollDatabase(paths.databasePath); + const { client } = getLoopndrollDatabase(paths.databasePath); - if (getSettingsRow().hooksAutoRegistration) { + const settingsRow = getSettingsRow(); + if ( + settingsRow.hooksAutoRegistration && + normalizeLoopndrollRuntimeState(settingsRow.runtimeState) !== "stopped" + ) { await ensureRegistered(paths); } + try { + await completePendingHookRemovalIfSafe(); + } catch (error) { + await appendHookDebugLog(paths, { + type: "setup", + action: "pending-hook-removal-recheck-failed", + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const refreshResult = await refreshCanonicalThreadNames(client); + if ( + refreshResult.refreshedCount > 0 || + refreshResult.orphanedMissCountUpdated > 0 || + refreshResult.prunedCount > 0 || + refreshResult.resetCount > 0 + ) { + await appendHookDebugLog(paths, { + type: "setup", + action: "refresh-canonical-thread-names", + ...refreshResult, + }); + } + } catch (error) { + await appendHookDebugLog(paths, { + type: "setup", + action: "refresh-canonical-thread-names", + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + return loadSnapshot(paths); } export async function getLoopndrollSnapshot() { const paths = getLoopndrollPaths(); + await completePendingHookRemovalIfSafe(); return loadSnapshot(paths); } @@ -276,29 +732,111 @@ export async function registerHooks() { const { db } = getLoopndrollDatabase(paths.databasePath); await ensureRegistered(paths); - db.update(settings).set({ hooksAutoRegistration: true }).where(eq(settings.id, 1)).run(); + db.update(settings) + .set({ + hooksAutoRegistration: true, + runtimeState: "running", + hookRemovalPending: false, + hookRemovalNextAttemptAt: null, + }) + .where(eq(settings.id, 1)) + .run(); + setHookLifecycleStatus( + buildHookLifecycleStatus({ + requestedAction: "start", + appliedAction: "running", + deferredAction: "none", + remainingRisk: "runtime-unload-unproven", + nextAutomaticStep: + "Start a new Codex turn or restart the app-server lane if live hook load is stale.", + message: + "Loopndroll hooks were installed in hooks.json; live Codex runtime load is not assumed.", + pending: false, + objectives: { + inertNow: false, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + }), + ); return loadSnapshot(paths); } export async function clearHooks() { - const paths = getLoopndrollPaths(); - const { db } = getLoopndrollDatabase(paths.databasePath); - const hooksDocument = await loadHooksDocument(paths); - - removeManagedHooks(hooksDocument); - await writeFile(paths.codexHooksPath, `${JSON.stringify(hooksDocument, null, 2)}\n`, "utf8"); - db.update(settings).set({ hooksAutoRegistration: false }).where(eq(settings.id, 1)).run(); + await applyIntelligentManagedHookRemoval("clear-managed-hook"); + return loadSnapshot(getLoopndrollPaths()); +} +export async function pauseLoopndroll() { + const paths = getLoopndrollPaths(); + setRuntimeState("paused"); + setHookLifecycleStatus( + buildHookLifecycleStatus({ + requestedAction: "pause", + appliedAction: "soft-pause", + deferredAction: "none", + remainingRisk: "none", + nextAutomaticStep: null, + message: "soft pause applied because active processes were detected", + pending: false, + objectives: { + inertNow: true, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + }), + ); await appendHookDebugLog(paths, { type: "setup", - action: "clear-hooks", - hooksFilePath: paths.codexHooksPath, + action: "pause-loopndroll", }); + return loadSnapshot(paths); +} +export async function resumeLoopndroll() { + const paths = getLoopndrollPaths(); + const { db } = getLoopndrollDatabase(paths.databasePath); + db.update(settings) + .set({ + runtimeState: "running", + hookRemovalPending: false, + hookRemovalNextAttemptAt: null, + }) + .where(eq(settings.id, 1)) + .run(); + setHookLifecycleStatus( + buildHookLifecycleStatus({ + requestedAction: "resume", + appliedAction: "running", + deferredAction: "none", + remainingRisk: "none", + nextAutomaticStep: null, + message: "Loopndroll resumed; pending hook removal was cancelled.", + pending: false, + objectives: { + inertNow: false, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + }), + ); + await appendHookDebugLog(paths, { + type: "setup", + action: "resume-loopndroll", + }); return loadSnapshot(paths); } +export async function stopLoopndroll() { + await applyIntelligentManagedHookRemoval("stop"); + return loadSnapshot(getLoopndrollPaths()); +} + +export async function startLoopndroll() { + return registerHooks(); +} + export async function revealHooksFile() { const paths = getLoopndrollPaths(); await ensureDirectory(paths.codexDirectoryPath); diff --git a/src/bun/hook-removal-watch-lock.test.ts b/src/bun/hook-removal-watch-lock.test.ts new file mode 100644 index 0000000..ea5a693 --- /dev/null +++ b/src/bun/hook-removal-watch-lock.test.ts @@ -0,0 +1,84 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import type { LoopndrollPaths } from "./loopndroll-core"; +import { + acquireHookRemovalWatchLock, + getHookRemovalWatcherStatus, + releaseHookRemovalWatchLock, +} from "./hook-removal-watch-lock"; + +async function createTestPaths() { + const appDirectoryPath = await mkdtemp(join(tmpdir(), "loopndroll-watch-lock-")); + const paths = { + appDirectoryPath, + binDirectoryPath: join(appDirectoryPath, "bin"), + stateDirectoryPath: join(appDirectoryPath, "state"), + logsDirectoryPath: join(appDirectoryPath, "logs"), + databasePath: join(appDirectoryPath, "app.db"), + managedHookPath: join(appDirectoryPath, "bin", "loopndroll-hook"), + hookRemovalWatchLockPath: join(appDirectoryPath, "state", "hook-removal-watch.lock"), + startupRecoveryMarkerPath: join(appDirectoryPath, "state", "startup-runtime.marker.json"), + hookDebugLogPath: join(appDirectoryPath, "logs", "hooks-debug.jsonl"), + codexDirectoryPath: join(appDirectoryPath, ".codex"), + codexConfigPath: join(appDirectoryPath, ".codex", "config.toml"), + codexHooksPath: join(appDirectoryPath, ".codex", "hooks.json"), + } satisfies LoopndrollPaths; + + return { + paths, + async cleanup() { + await rm(appDirectoryPath, { recursive: true, force: true }); + }, + }; +} + +describe("hook removal watcher lock", () => { + test("allows only one watcher owner for the same hook-state lock", async () => { + const { paths, cleanup } = await createTestPaths(); + try { + const first = await acquireHookRemovalWatchLock(paths, "/repo/a"); + const second = await acquireHookRemovalWatchLock(paths, "/repo/a"); + const status = await getHookRemovalWatcherStatus(paths); + + expect(first.status).toBe("acquired"); + expect(second.status).toBe("already-running"); + expect(second.message).toBe("watcher already running"); + expect(second.lock.pid).toBe(first.lock.pid); + expect(status.active).toBe(true); + expect(status.pid).toBe(process.pid); + expect(status.repoRoot).toBe("/repo/a"); + } finally { + await releaseHookRemovalWatchLock(paths); + await cleanup(); + } + }); + + test("replaces a stale lock whose pid is no longer alive", async () => { + const { paths, cleanup } = await createTestPaths(); + try { + await mkdir(paths.stateDirectoryPath, { recursive: true }); + await writeFile( + paths.hookRemovalWatchLockPath, + `${JSON.stringify({ + pid: 0, + started_at: "2026-04-24T00:00:00.000Z", + repo_root: "/stale", + hooks_path: paths.codexHooksPath, + runtime_state_path: paths.databasePath, + })}\n`, + "utf8", + ); + + const result = await acquireHookRemovalWatchLock(paths, "/repo/b"); + + expect(result.status).toBe("replaced-stale"); + expect(result.lock.pid).toBe(process.pid); + expect(result.lock.repo_root).toBe("/repo/b"); + } finally { + await releaseHookRemovalWatchLock(paths); + await cleanup(); + } + }); +}); diff --git a/src/bun/hook-removal-watch-lock.ts b/src/bun/hook-removal-watch-lock.ts new file mode 100644 index 0000000..471b6d0 --- /dev/null +++ b/src/bun/hook-removal-watch-lock.ts @@ -0,0 +1,197 @@ +import { open, readFile, unlink } from "node:fs/promises"; +import { readFileSync, unlinkSync } from "node:fs"; +import type { HookRemovalWatcherStatus } from "../shared/app-rpc"; +import type { LoopndrollPaths } from "./loopndroll-core"; +import { ensureDirectory, nowIsoString } from "./loopndroll-core"; + +export type HookRemovalWatchLock = { + pid: number; + started_at: string; + repo_root: string; + hooks_path: string; + runtime_state_path: string; +}; + +export type HookRemovalWatchLockAcquireResult = + | { status: "acquired"; lock: HookRemovalWatchLock; message: string } + | { status: "already-running"; lock: HookRemovalWatchLock; message: string } + | { + status: "replaced-stale"; + lock: HookRemovalWatchLock; + previousLock: HookRemovalWatchLock | null; + message: string; + }; + +function isErrno(error: unknown, code: string) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === code + ); +} + +function createHookRemovalWatchLock(paths: LoopndrollPaths, repoRoot: string) { + return { + pid: process.pid, + started_at: nowIsoString(), + repo_root: repoRoot, + hooks_path: paths.codexHooksPath, + runtime_state_path: paths.databasePath, + } satisfies HookRemovalWatchLock; +} + +export function isProcessAlive(pid: number) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isErrno(error, "ESRCH"); + } +} + +function parseHookRemovalWatchLock(raw: string | null): HookRemovalWatchLock | null { + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.pid !== "number" || + typeof parsed.started_at !== "string" || + typeof parsed.repo_root !== "string" || + typeof parsed.hooks_path !== "string" || + typeof parsed.runtime_state_path !== "string" + ) { + return null; + } + + return { + pid: parsed.pid, + started_at: parsed.started_at, + repo_root: parsed.repo_root, + hooks_path: parsed.hooks_path, + runtime_state_path: parsed.runtime_state_path, + }; + } catch { + return null; + } +} + +async function readHookRemovalWatchLock(lockPath: string) { + return parseHookRemovalWatchLock(await readFile(lockPath, "utf8").catch(() => null)); +} + +async function writeHookRemovalWatchLockAtomically(lockPath: string, lock: HookRemovalWatchLock) { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile(`${JSON.stringify(lock, null, 2)}\n`, "utf8"); + } finally { + await handle.close(); + } +} + +export async function acquireHookRemovalWatchLock( + paths: LoopndrollPaths, + repoRoot = process.cwd(), +): Promise { + await ensureDirectory(paths.stateDirectoryPath); + let staleLock: HookRemovalWatchLock | null = null; + + for (;;) { + const lock = createHookRemovalWatchLock(paths, repoRoot); + try { + await writeHookRemovalWatchLockAtomically(paths.hookRemovalWatchLockPath, lock); + return staleLock + ? { + status: "replaced-stale", + lock, + previousLock: staleLock, + message: "stale watcher lock replaced", + } + : { status: "acquired", lock, message: "watcher lock acquired" }; + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + + const existingLock = await readHookRemovalWatchLock(paths.hookRemovalWatchLockPath); + if (existingLock && isProcessAlive(existingLock.pid)) { + return { + status: "already-running", + lock: existingLock, + message: "watcher already running", + }; + } + + staleLock = existingLock; + await unlink(paths.hookRemovalWatchLockPath).catch((error) => { + if (!isErrno(error, "ENOENT")) { + throw error; + } + }); + } +} + +export async function releaseHookRemovalWatchLock(paths: LoopndrollPaths, pid = process.pid) { + const lock = await readHookRemovalWatchLock(paths.hookRemovalWatchLockPath); + if (!lock || lock.pid !== pid) { + return false; + } + + await unlink(paths.hookRemovalWatchLockPath).catch((error) => { + if (!isErrno(error, "ENOENT")) { + throw error; + } + }); + return true; +} + +export function releaseHookRemovalWatchLockSync(paths: LoopndrollPaths, pid = process.pid) { + const lock = parseHookRemovalWatchLock( + (() => { + try { + return readFileSync(paths.hookRemovalWatchLockPath, "utf8"); + } catch { + return null; + } + })(), + ); + if (!lock || lock.pid !== pid) { + return false; + } + + try { + unlinkSync(paths.hookRemovalWatchLockPath); + return true; + } catch (error) { + if (isErrno(error, "ENOENT")) { + return true; + } + return false; + } +} + +export async function getHookRemovalWatcherStatus( + paths: LoopndrollPaths, +): Promise { + const lock = await readHookRemovalWatchLock(paths.hookRemovalWatchLockPath); + const active = lock ? isProcessAlive(lock.pid) : false; + + return { + active, + pid: lock?.pid ?? null, + lockPath: paths.hookRemovalWatchLockPath, + startedAt: lock?.started_at ?? null, + repoRoot: lock?.repo_root ?? null, + hooksPath: lock?.hooks_path ?? null, + runtimeStatePath: lock?.runtime_state_path ?? null, + message: active ? `watcher active: pid ${lock?.pid}` : "watcher not running", + }; +} diff --git a/src/bun/index-loopndroll-rpc.test.ts b/src/bun/index-loopndroll-rpc.test.ts new file mode 100644 index 0000000..3e3de4e --- /dev/null +++ b/src/bun/index-loopndroll-rpc.test.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, test } from "bun:test"; +import type { AppRpcSchema } from "../shared/app-rpc"; + +type LoopndrollSetupRequestName = keyof Pick< + AppRpcSchema["bun"]["requests"], + "ensureLoopndrollSetup" | "getLoopndrollState" +>; + +const LOOPNDROLL_SETUP_REQUEST_NAMES = [ + "ensureLoopndrollSetup", + "getLoopndrollState", +] satisfies LoopndrollSetupRequestName[]; + +describe("loopndroll product RPC setup surface", () => { + test("keeps the setup request names in the RPC schema", () => { + const requestNames: LoopndrollSetupRequestName[] = [...LOOPNDROLL_SETUP_REQUEST_NAMES]; + + expect(requestNames).toEqual(["ensureLoopndrollSetup", "getLoopndrollState"]); + }); + + test("wires the setup request handlers in the product RPC entrypoint", async () => { + const source = await readFile(new URL("./index.ts", import.meta.url), "utf8"); + + expect(source).toContain("ensureLoopndrollSetup,"); + expect(source).toContain("getLoopndrollState: getLoopndrollSnapshot,"); + }); +}); diff --git a/src/bun/index.ts b/src/bun/index.ts index 713048a..2c00130 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -16,24 +16,33 @@ import { clearHooks, createCompletionCheck, createLoopNotification, + clearStartupRecoveryMarker, deleteCompletionCheck, deleteLoopNotification, ensureLoopndrollSetup, getTelegramChats as fetchTelegramChats, getLoopndrollSnapshot, + migrateNotificationSecretsToKeychain, + pauseLoopndroll, registerHooks, revealHooksFile, + resetActiveLoopStateOnStartup, + resumeLoopndroll, saveDefaultPrompt, deleteSession, setGlobalCompletionCheckConfig, setGlobalNotification, setGlobalPreset, + setMirrorEnabled, setSessionArchived as persistSessionArchived, setSessionCompletionCheckConfig, setSessionNotifications as persistSessionNotifications, setLoopScope, setSessionPreset, + startLoopndroll, + startHookRemovalPendingMonitor, startLoopndrollTelegramBridge, + stopLoopndroll, updateCompletionCheck, updateLoopNotification, } from "./loopndroll"; @@ -511,6 +520,62 @@ function getAppRpcRequestHandlers() { }; } +function getLoopndrollSessionRpcRequestHandlers() { + return { + setSessionNotifications({ + sessionId, + notificationIds, + }: { + sessionId: string; + notificationIds: string[]; + }) { + return persistSessionNotifications(sessionId, notificationIds); + }, + setSessionPreset({ + sessionId, + preset, + }: { + sessionId: string; + preset: Parameters[1]; + }) { + return setSessionPreset(sessionId, preset); + }, + setSessionCompletionCheckConfig({ + sessionId, + completionCheckId, + waitForReplyAfterCompletion, + }: { + sessionId: string; + completionCheckId: string | null; + waitForReplyAfterCompletion: boolean; + }) { + return setSessionCompletionCheckConfig( + sessionId, + completionCheckId, + waitForReplyAfterCompletion, + ); + }, + setSessionArchived({ sessionId, archived }: { sessionId: string; archived: boolean }) { + return persistSessionArchived(sessionId, archived); + }, + deleteSession({ sessionId }: { sessionId: string }) { + return deleteSession(sessionId); + }, + }; +} + +function getLoopndrollLifecycleRpcRequestHandlers() { + return { + registerHooks, + clearHooks, + pauseLoopndroll, + resumeLoopndroll, + startLoopndroll, + stopLoopndroll, + revealHooksFile, + }; +} + function getLoopndrollRpcRequestHandlers() { return { ensureLoopndrollSetup, @@ -518,7 +583,11 @@ function getLoopndrollRpcRequestHandlers() { saveDefaultPrompt({ defaultPrompt }: { defaultPrompt: string }) { return saveDefaultPrompt(defaultPrompt); }, - createNotification({ notification }: { notification: Parameters[0] }) { + createNotification({ + notification, + }: { + notification: Parameters[0]; + }) { return createLoopNotification(notification); }, createCompletionCheck({ @@ -538,6 +607,7 @@ function getLoopndrollRpcRequestHandlers() { }) { return updateLoopNotification(notification); }, + migrateNotificationSecretsToKeychain, updateCompletionCheck({ completionCheck, }: { @@ -545,9 +615,6 @@ function getLoopndrollRpcRequestHandlers() { }) { return updateCompletionCheck(completionCheck); }, - setSessionNotifications({ sessionId, notificationIds }: { sessionId: string; notificationIds: string[] }) { - return persistSessionNotifications(sessionId, notificationIds); - }, deleteNotification({ notificationId }: { notificationId: string }) { return deleteLoopNotification(notificationId); }, @@ -572,33 +639,11 @@ function getLoopndrollRpcRequestHandlers() { }) { return setGlobalCompletionCheckConfig(completionCheckId, waitForReplyAfterCompletion); }, - setSessionPreset({ sessionId, preset }: { sessionId: string; preset: Parameters[1] }) { - return setSessionPreset(sessionId, preset); - }, - setSessionCompletionCheckConfig({ - sessionId, - completionCheckId, - waitForReplyAfterCompletion, - }: { - sessionId: string; - completionCheckId: string | null; - waitForReplyAfterCompletion: boolean; - }) { - return setSessionCompletionCheckConfig( - sessionId, - completionCheckId, - waitForReplyAfterCompletion, - ); - }, - setSessionArchived({ sessionId, archived }: { sessionId: string; archived: boolean }) { - return persistSessionArchived(sessionId, archived); + setMirrorEnabled({ enabled }: { enabled: boolean }) { + return setMirrorEnabled(enabled); }, - deleteSession({ sessionId }: { sessionId: string }) { - return deleteSession(sessionId); - }, - registerHooks, - clearHooks, - revealHooksFile, + ...getLoopndrollSessionRpcRequestHandlers(), + ...getLoopndrollLifecycleRpcRequestHandlers(), }; } @@ -616,8 +661,40 @@ function createWindowRpc() { const windowRpc = createWindowRpc(); +function registerStartupRecoveryCleanup() { + let cleaned = false; + const cleanup = () => { + if (cleaned) { + return; + } + cleaned = true; + clearStartupRecoveryMarker(); + }; + + process.once("exit", cleanup); + process.once("SIGTERM", () => { + cleanup(); + process.exit(0); + }); + process.once("SIGINT", () => { + cleanup(); + process.exit(0); + }); +} + installApplicationMenu(); -startLoopndrollTelegramBridge(); +try { + resetActiveLoopStateOnStartup(); + registerStartupRecoveryCleanup(); +} catch (error) { + console.error("Loopndroll startup active-state reset failed.", error); +} +if (process.env["LOOPNDROLL_DISABLE_HOOK_REMOVAL_MONITOR"] !== "1") { + void startHookRemovalPendingMonitor(); +} +if (process.env["LOOPNDROLL_DISABLE_TELEGRAM_BRIDGE"] !== "1") { + startLoopndrollTelegramBridge(); +} void initializeUpdater(); mainWindow = new BrowserWindow({ diff --git a/src/bun/loopndroll-actions-secret-migration.test.ts b/src/bun/loopndroll-actions-secret-migration.test.ts new file mode 100644 index 0000000..7b12203 --- /dev/null +++ b/src/bun/loopndroll-actions-secret-migration.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; + +import { redactPersistedTelegramBotToken } from "./loopndroll-actions"; + +function createTokenStateSchema(db: Database) { + db.exec(` + create table telegram_update_cursors ( + bot_token text primary key, + last_update_id integer not null, + updated_at text not null + ); + + create table telegram_known_chats ( + bot_token text not null, + chat_id text not null, + kind text not null, + username text, + display_name text not null, + updated_at text not null, + primary key (bot_token, chat_id) + ); + + create table session_awaiting_replies ( + thread_id text not null, + bot_token text not null, + chat_id text not null, + turn_id text, + started_at text not null, + primary key (thread_id, bot_token, chat_id) + ); + + create table telegram_delivery_receipts ( + id text primary key, + notification_id text, + thread_id text not null, + bot_token text not null, + chat_id text not null, + telegram_message_id integer not null, + created_at text not null + ); + `); +} + +describe("telegram bot token state redaction", () => { + test("moves token-scoped bridge state to the migration ref without dropping it", () => { + const db = new Database(":memory:"); + createTokenStateSchema(db); + db.query( + `insert into telegram_update_cursors (bot_token, last_update_id, updated_at) + values ('plain-token', 41, '2026-04-30T00:00:00.000Z')`, + ).run(); + db.query( + `insert into telegram_known_chats ( + bot_token, + chat_id, + kind, + username, + display_name, + updated_at + ) values ('plain-token', '123', 'private', 'user', 'User', '2026-04-30T00:00:00.000Z')`, + ).run(); + db.query( + `insert into session_awaiting_replies ( + thread_id, + bot_token, + chat_id, + turn_id, + started_at + ) values ('thread-1', 'plain-token', '123', 'turn-1', '2026-04-30T00:00:00.000Z')`, + ).run(); + db.query( + `insert into telegram_delivery_receipts ( + id, + notification_id, + thread_id, + bot_token, + chat_id, + telegram_message_id, + created_at + ) values ('receipt-1', 'notification-1', 'thread-1', 'plain-token', '123', 10, '2026-04-30T00:00:00.000Z')`, + ).run(); + + redactPersistedTelegramBotToken( + db, + "plain-token", + "keychain://loopndroll/telegram-bot-token/notification-1", + ); + + expect(db.query("select count(*) as count from telegram_update_cursors").get()).toEqual({ + count: 1, + }); + expect(db.query("select bot_token, last_update_id from telegram_update_cursors").get()).toEqual( + { + bot_token: "keychain://loopndroll/telegram-bot-token/notification-1", + last_update_id: 41, + }, + ); + expect(db.query("select bot_token, chat_id from telegram_known_chats").get()).toEqual({ + bot_token: "keychain://loopndroll/telegram-bot-token/notification-1", + chat_id: "123", + }); + expect(db.query("select bot_token, chat_id from session_awaiting_replies").get()).toEqual({ + bot_token: "keychain://loopndroll/telegram-bot-token/notification-1", + chat_id: "123", + }); + expect(db.query("select bot_token, chat_id from telegram_delivery_receipts").get()).toEqual({ + bot_token: "keychain://loopndroll/telegram-bot-token/notification-1", + chat_id: "123", + }); + }); +}); diff --git a/src/bun/loopndroll-actions.ts b/src/bun/loopndroll-actions.ts index aacef9b..aca8ba6 100644 --- a/src/bun/loopndroll-actions.ts +++ b/src/bun/loopndroll-actions.ts @@ -1,10 +1,12 @@ import { and, asc, eq } from "drizzle-orm"; +import { readFile, writeFile } from "node:fs/promises"; import type { CreateLoopNotificationInput, LoopPreset, LoopScope, UpdateLoopNotificationInput, } from "../shared/app-rpc"; +import { validateTelegramNotificationChatId } from "../shared/telegram-chat-policy"; import { DEFAULT_PROMPT } from "./constants"; import { getLoopndrollDatabase } from "./db/client"; import { @@ -22,7 +24,7 @@ import { allocateNextSessionRef, applyGlobalNotificationToSession, buildNewSession, - buildTelegramBotUrl, + buildTelegramBotUrlForStorage, createNotification, getLoopndrollPaths, getNotificationBaseLabel, @@ -41,6 +43,141 @@ import { resolveSessionPresetState, stringifyCompletionCheckCommands, } from "./loopndroll-core"; +import { + deleteSlackWebhookUrlFromKeychain, + deleteTelegramBotTokenFromKeychain, + getTelegramBotTokenMigrationRef, + isSlackWebhookUrlKeychainRef, + isTelegramBotTokenKeychainRef, + resolveSlackWebhookUrl, + resolveTelegramBotToken, + storeSlackWebhookUrlInKeychain, + storeTelegramBotTokenInKeychain, +} from "./secret-store"; + +async function redactSecretsFromManagedLogs(secrets: string[]) { + const redactionTargets = [...new Set(secrets.map((secret) => secret.trim()))].filter( + (secret) => + secret.length > 0 && + !isTelegramBotTokenKeychainRef(secret) && + !isSlackWebhookUrlKeychainRef(secret), + ); + if (redactionTargets.length === 0) { + return; + } + + const { hookDebugLogPath } = getLoopndrollPaths(); + let currentLog: string; + try { + currentLog = await readFile(hookDebugLogPath, "utf8"); + } catch { + return; + } + + let nextLog = currentLog; + for (const secret of redactionTargets) { + nextLog = nextLog.replaceAll(secret, "[redacted]"); + } + + if (nextLog !== currentLog) { + await writeFile(hookDebugLogPath, nextLog, "utf8"); + } +} + +export function redactPersistedTelegramBotToken( + client: ReturnType["client"], + plaintextBotToken: string, + botTokenRef: string, +) { + const oldToken = plaintextBotToken.trim(); + const nextToken = botTokenRef.trim(); + if (oldToken.length === 0 || oldToken === nextToken || isTelegramBotTokenKeychainRef(oldToken)) { + return; + } + + client.transaction(() => { + client + .query( + `insert into telegram_update_cursors (bot_token, last_update_id, updated_at) + select ?, last_update_id, updated_at + from telegram_update_cursors + where bot_token = ? + on conflict(bot_token) do update set + last_update_id = max(telegram_update_cursors.last_update_id, excluded.last_update_id), + updated_at = excluded.updated_at`, + ) + .run(nextToken, oldToken); + client.query("delete from telegram_update_cursors where bot_token = ?").run(oldToken); + + client + .query( + `insert into telegram_known_chats ( + bot_token, + chat_id, + kind, + username, + display_name, + updated_at + ) + select ?, chat_id, kind, username, display_name, updated_at + from telegram_known_chats + where bot_token = ? + on conflict(bot_token, chat_id) do update set + kind = excluded.kind, + username = excluded.username, + display_name = excluded.display_name, + updated_at = excluded.updated_at`, + ) + .run(nextToken, oldToken); + client.query("delete from telegram_known_chats where bot_token = ?").run(oldToken); + + client + .query( + `insert into session_awaiting_replies ( + thread_id, + bot_token, + chat_id, + turn_id, + started_at + ) + select thread_id, ?, chat_id, turn_id, started_at + from session_awaiting_replies + where bot_token = ? + on conflict(thread_id, bot_token, chat_id) do update set + turn_id = excluded.turn_id, + started_at = excluded.started_at`, + ) + .run(nextToken, oldToken); + client.query("delete from session_awaiting_replies where bot_token = ?").run(oldToken); + + client + .query("update or ignore telegram_delivery_receipts set bot_token = ? where bot_token = ?") + .run(nextToken, oldToken); + client.query("delete from telegram_delivery_receipts where bot_token = ?").run(oldToken); + })(); +} + +function deleteTelegramBotTokenFromKeychainIfUnused( + db: ReturnType["db"], + botTokenOrRef: string | null | undefined, +) { + if (typeof botTokenOrRef !== "string" || !isTelegramBotTokenKeychainRef(botTokenOrRef)) { + return; + } + const botTokenRef = botTokenOrRef.trim(); + + const remainingRef = db + .select({ id: notifications.id }) + .from(notifications) + .where(and(eq(notifications.channel, "telegram"), eq(notifications.botToken, botTokenRef))) + .limit(1) + .get(); + if (remainingRef) { + return; + } + + deleteTelegramBotTokenFromKeychain(botTokenRef); +} export async function saveDefaultPrompt(defaultPrompt: string) { const paths = getLoopndrollPaths(); @@ -56,9 +193,31 @@ export async function saveDefaultPrompt(defaultPrompt: string) { export async function createLoopNotification(notification: CreateLoopNotificationInput) { const paths = getLoopndrollPaths(); - const { db } = getLoopndrollDatabase(paths.databasePath); + const { client, db } = getLoopndrollDatabase(paths.databasePath); const existingNotifications = readSnapshotFromDatabase().notifications; + if (notification.channel === "telegram") { + const chatError = validateTelegramNotificationChatId(notification.chatId.trim()); + if (chatError) { + throw new Error(chatError); + } + } const nextNotification = createNotification(notification); + if (nextNotification.channel === "slack") { + const plaintextWebhookUrl = nextNotification.webhookUrl; + nextNotification.webhookUrl = storeSlackWebhookUrlInKeychain( + nextNotification.id, + nextNotification.webhookUrl, + ); + await redactSecretsFromManagedLogs([plaintextWebhookUrl]); + } else { + const plaintextBotToken = nextNotification.botToken; + nextNotification.botToken = storeTelegramBotTokenInKeychain( + nextNotification.id, + nextNotification.botToken, + ); + redactPersistedTelegramBotToken(client, plaintextBotToken, nextNotification.botToken); + await redactSecretsFromManagedLogs([plaintextBotToken]); + } nextNotification.label = getUniqueNotificationLabel( existingNotifications, @@ -96,7 +255,13 @@ export async function createCompletionCheck(input: { label?: string; commands: s export async function updateLoopNotification(notification: UpdateLoopNotificationInput) { const paths = getLoopndrollPaths(); - const { db } = getLoopndrollDatabase(paths.databasePath); + const { client, db } = getLoopndrollDatabase(paths.databasePath); + if (notification.channel === "telegram") { + const chatError = validateTelegramNotificationChatId(notification.chatId.trim()); + if (chatError) { + throw new Error(chatError); + } + } const existingNotificationRows = db .select() .from(notifications) @@ -118,11 +283,19 @@ export async function updateLoopNotification(notification: UpdateLoopNotificatio ); if (notification.channel === "slack") { + const previousWebhookUrl = + currentNotification.channel === "slack" ? currentNotification.webhookUrl : ""; + const previousBotToken = + currentNotification.channel === "telegram" ? currentNotification.botToken : ""; + const webhookUrlRef = isSlackWebhookUrlKeychainRef(notification.webhookUrl) + ? notification.webhookUrl.trim() + : storeSlackWebhookUrlInKeychain(notification.id, notification.webhookUrl); + await redactSecretsFromManagedLogs([notification.webhookUrl, previousWebhookUrl]); db.update(notifications) .set({ label, channel: "slack", - webhookUrl: notification.webhookUrl.trim(), + webhookUrl: webhookUrlRef, chatId: null, botToken: null, botUrl: null, @@ -131,20 +304,34 @@ export async function updateLoopNotification(notification: UpdateLoopNotificatio }) .where(eq(notifications.id, notification.id)) .run(); + deleteTelegramBotTokenFromKeychainIfUnused(db, previousBotToken); } else { + const previousBotToken = + currentNotification.channel === "telegram" ? currentNotification.botToken : ""; + if (currentNotification.channel === "slack") { + deleteSlackWebhookUrlFromKeychain(currentNotification.webhookUrl); + } + const plaintextBotToken = notification.botToken.trim(); + const botTokenRef = isTelegramBotTokenKeychainRef(notification.botToken) + ? notification.botToken.trim() + : storeTelegramBotTokenInKeychain(notification.id, notification.botToken); + redactPersistedTelegramBotToken(client, plaintextBotToken, botTokenRef); + redactPersistedTelegramBotToken(client, previousBotToken, botTokenRef); + await redactSecretsFromManagedLogs([plaintextBotToken, previousBotToken]); db.update(notifications) .set({ label, channel: "telegram", webhookUrl: null, chatId: notification.chatId.trim(), - botToken: notification.botToken.trim(), - botUrl: buildTelegramBotUrl(notification.botToken.trim()), + botToken: botTokenRef, + botUrl: buildTelegramBotUrlForStorage(botTokenRef), chatUsername: notification.chatUsername?.trim() || null, chatDisplayName: notification.chatDisplayName?.trim() || null, }) .where(eq(notifications.id, notification.id)) .run(); + deleteTelegramBotTokenFromKeychainIfUnused(db, previousBotToken); } return loadSnapshot(paths); @@ -188,6 +375,11 @@ export async function updateCompletionCheck(input: { export async function deleteLoopNotification(notificationId: string) { const paths = getLoopndrollPaths(); const { db } = getLoopndrollDatabase(paths.databasePath); + const existingNotification = db + .select() + .from(notifications) + .where(eq(notifications.id, notificationId)) + .get(); db.transaction((tx) => { tx.delete(notifications).where(eq(notifications.id, notificationId)).run(); @@ -196,6 +388,77 @@ export async function deleteLoopNotification(notificationId: string) { .where(eq(settings.globalNotificationId, notificationId)) .run(); }); + deleteTelegramBotTokenFromKeychainIfUnused(db, existingNotification?.botToken); + deleteSlackWebhookUrlFromKeychain(existingNotification?.webhookUrl); + + return loadSnapshot(paths); +} + +export async function migrateNotificationSecretsToKeychain() { + const paths = getLoopndrollPaths(); + const { client, db } = getLoopndrollDatabase(paths.databasePath); + const existingNotificationRows = db + .select() + .from(notifications) + .orderBy(asc(notifications.createdAt), asc(notifications.id)) + .all(); + const migratedTelegramBotTokenRefs = new Map(); + + for (const row of existingNotificationRows) { + const currentNotification = mapNotificationRow(row); + if (currentNotification.channel === "slack") { + const webhookUrl = currentNotification.webhookUrl.trim(); + if (webhookUrl.length === 0) { + continue; + } + + if (isSlackWebhookUrlKeychainRef(webhookUrl)) { + await redactSecretsFromManagedLogs([resolveSlackWebhookUrl(webhookUrl)]); + continue; + } + + db.update(notifications) + .set({ + webhookUrl: storeSlackWebhookUrlInKeychain(currentNotification.id, webhookUrl), + }) + .where(eq(notifications.id, currentNotification.id)) + .run(); + await redactSecretsFromManagedLogs([webhookUrl]); + continue; + } + + const botToken = currentNotification.botToken.trim(); + if (botToken.length === 0) { + continue; + } + + if (isTelegramBotTokenKeychainRef(botToken)) { + const resolvedBotToken = resolveTelegramBotToken(botToken); + redactPersistedTelegramBotToken(client, resolvedBotToken, botToken); + await redactSecretsFromManagedLogs([resolvedBotToken]); + continue; + } + + const botTokenMigration = getTelegramBotTokenMigrationRef( + currentNotification.id, + botToken, + migratedTelegramBotTokenRefs, + ); + let botTokenRef = botTokenMigration.ref; + if (botTokenMigration.shouldStore) { + botTokenRef = storeTelegramBotTokenInKeychain(currentNotification.id, botToken); + migratedTelegramBotTokenRefs.set(botToken, botTokenRef); + } + redactPersistedTelegramBotToken(client, botToken, botTokenRef); + await redactSecretsFromManagedLogs([botToken]); + db.update(notifications) + .set({ + botToken: botTokenRef, + botUrl: buildTelegramBotUrlForStorage(botTokenRef), + }) + .where(eq(notifications.id, currentNotification.id)) + .run(); + } return loadSnapshot(paths); } @@ -238,14 +501,14 @@ export async function setSessionNotifications(sessionId: string, notificationIds const existingSession = tx .select() .from(sessions) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .get(); if (!existingSession) { tx.insert(sessions).values(buildNewSession(sessionId, nextSessionRef)).run(); applyGlobalNotificationToSession(tx, sessionId, getStoredGlobalNotificationId(tx)); } - tx.delete(sessionNotifications).where(eq(sessionNotifications.sessionId, sessionId)).run(); + tx.delete(sessionNotifications).where(eq(sessionNotifications.threadId, sessionId)).run(); if (existingSession?.archived) { return; @@ -255,7 +518,7 @@ export async function setSessionNotifications(sessionId: string, notificationIds tx.insert(sessionNotifications) .values( dedupedNotificationIds.map((notificationId) => ({ - sessionId, + threadId: sessionId, notificationId, })), ) @@ -300,8 +563,8 @@ export async function setGlobalPreset(preset: LoopPreset | null) { if (preset !== "await-reply") { tx.run( `delete from session_awaiting_replies - where session_id in ( - select session_id + where thread_id in ( + select thread_id from sessions where preset is null and preset_overridden = 0 @@ -312,8 +575,8 @@ export async function setGlobalPreset(preset: LoopPreset | null) { if (preset === null) { tx.run( `delete from session_remote_prompts - where session_id in ( - select session_id + where thread_id in ( + select thread_id from sessions where preset is null and preset_overridden = 0 @@ -326,8 +589,8 @@ export async function setGlobalPreset(preset: LoopPreset | null) { tx.run( `delete from session_remote_prompts where delivery_mode = 'persistent' - and session_id in ( - select session_id + and thread_id in ( + select thread_id from sessions where preset is null and preset_overridden = 0 @@ -387,6 +650,15 @@ export async function setGlobalCompletionCheckConfig( return loadSnapshot(paths); } +export async function setMirrorEnabled(enabled: boolean) { + const paths = getLoopndrollPaths(); + const { db } = getLoopndrollDatabase(paths.databasePath); + + db.update(settings).set({ mirrorEnabled: enabled }).where(eq(settings.id, 1)).run(); + + return loadSnapshot(paths); +} + export async function setSessionCompletionCheckConfig( sessionId: string, completionCheckId: string | null, @@ -408,7 +680,7 @@ export async function setSessionCompletionCheckConfig( const existingSession = tx .select() .from(sessions) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .get(); if (!existingSession) { @@ -422,14 +694,14 @@ export async function setSessionCompletionCheckConfig( completionCheckWaitForReply: nextCompletionCheckId === null ? false : waitForReplyAfterCompletion, }) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .run(); if (nextCompletionCheckId !== null) { return; } - tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.sessionId, sessionId)).run(); + tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.threadId, sessionId)).run(); }); return loadSnapshot(paths); @@ -444,7 +716,7 @@ export async function setSessionPreset(sessionId: string, preset: LoopPreset | n const existingSession = tx .select() .from(sessions) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .get(); if (!existingSession) { @@ -461,13 +733,11 @@ export async function setSessionPreset(sessionId: string, preset: LoopPreset | n completionCheckId: null, completionCheckWaitForReply: false, }) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .run(); - tx.delete(sessionRuntime).where(eq(sessionRuntime.sessionId, sessionId)).run(); - tx.delete(sessionAwaitingReplies) - .where(eq(sessionAwaitingReplies.sessionId, sessionId)) - .run(); - tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.sessionId, sessionId)).run(); + tx.delete(sessionRuntime).where(eq(sessionRuntime.threadId, sessionId)).run(); + tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.threadId, sessionId)).run(); + tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.threadId, sessionId)).run(); return; } @@ -490,27 +760,25 @@ export async function setSessionPreset(sessionId: string, preset: LoopPreset | n presetOverridden: true, activeSince: nextActiveSince, }) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .run(); - tx.delete(sessionRuntime).where(eq(sessionRuntime.sessionId, sessionId)).run(); + tx.delete(sessionRuntime).where(eq(sessionRuntime.threadId, sessionId)).run(); if (preset !== "await-reply") { - tx.delete(sessionAwaitingReplies) - .where(eq(sessionAwaitingReplies.sessionId, sessionId)) - .run(); + tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.threadId, sessionId)).run(); } if (isRestartingFromOff) { - tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.sessionId, sessionId)).run(); + tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.threadId, sessionId)).run(); return; } if (preset === null) { - tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.sessionId, sessionId)).run(); + tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.threadId, sessionId)).run(); return; } if (!isPersistentPromptPreset(preset)) { tx.delete(sessionRemotePrompts) .where( and( - eq(sessionRemotePrompts.sessionId, sessionId), + eq(sessionRemotePrompts.threadId, sessionId), eq(sessionRemotePrompts.deliveryMode, "persistent"), ), ) @@ -529,7 +797,7 @@ export async function setSessionArchived(sessionId: string, archived: boolean) { const existingSession = tx .select() .from(sessions) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .get(); if (!existingSession) { @@ -545,17 +813,17 @@ export async function setSessionArchived(sessionId: string, archived: boolean) { completionCheckId: archived ? null : existingSession.completionCheckId, completionCheckWaitForReply: archived ? false : existingSession.completionCheckWaitForReply, }) - .where(eq(sessions.sessionId, sessionId)) + .where(eq(sessions.threadId, sessionId)) .run(); if (!archived) { return; } - tx.delete(sessionNotifications).where(eq(sessionNotifications.sessionId, sessionId)).run(); - tx.delete(sessionRuntime).where(eq(sessionRuntime.sessionId, sessionId)).run(); - tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.sessionId, sessionId)).run(); - tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.sessionId, sessionId)).run(); + tx.delete(sessionNotifications).where(eq(sessionNotifications.threadId, sessionId)).run(); + tx.delete(sessionRuntime).where(eq(sessionRuntime.threadId, sessionId)).run(); + tx.delete(sessionAwaitingReplies).where(eq(sessionAwaitingReplies.threadId, sessionId)).run(); + tx.delete(sessionRemotePrompts).where(eq(sessionRemotePrompts.threadId, sessionId)).run(); }); return loadSnapshot(paths); @@ -565,7 +833,7 @@ export async function deleteSession(sessionId: string) { const paths = getLoopndrollPaths(); const { db } = getLoopndrollDatabase(paths.databasePath); - db.delete(sessions).where(eq(sessions.sessionId, sessionId)).run(); + db.delete(sessions).where(eq(sessions.threadId, sessionId)).run(); return loadSnapshot(paths); } diff --git a/src/bun/loopndroll-core.test.ts b/src/bun/loopndroll-core.test.ts new file mode 100644 index 0000000..c10b0b8 --- /dev/null +++ b/src/bun/loopndroll-core.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { isPromptOnlyArtifact } from "./loopndroll-core"; + +describe("isPromptOnlyArtifact", () => { + test("hides unresolved internal thread names even when a transcript exists", () => { + expect( + isPromptOnlyArtifact({ + transcriptPath: "/tmp/thread.jsonl", + threadName: "- Use `js_repl` for Node-backed JavaScript", + lastAssistantMessage: null, + }), + ).toBe(true); + }); + + test("keeps clean transcript-derived titles visible", () => { + expect( + isPromptOnlyArtifact({ + transcriptPath: "/tmp/thread.jsonl", + threadName: "Memory Writing Agent: Phase 2 (Consolidation)", + lastAssistantMessage: null, + }), + ).toBe(false); + }); +}); diff --git a/src/bun/loopndroll-core.ts b/src/bun/loopndroll-core.ts index 1615640..c7b9b1b 100644 --- a/src/bun/loopndroll-core.ts +++ b/src/bun/loopndroll-core.ts @@ -10,13 +10,22 @@ import type { LoopPreset, LoopScope, LoopSession, + HookLifecycleStatus, + LoopndrollSnapshot, LoopSessionPresetSource, + LoopndrollRuntimeState, } from "../shared/app-rpc"; import { + HOOK_LIFECYCLE_APPLIED_ACTION_VALUES, + HOOK_LIFECYCLE_DEFERRED_ACTION_VALUES, + HOOK_LIFECYCLE_REQUESTED_ACTION_VALUES, + HOOK_LIFECYCLE_RISK_VALUES, LOOP_PRESET_VALUES, + LOOPNDROLL_RUNTIME_STATE_VALUES, LOOP_SCOPE_VALUES, LOOP_SESSION_SOURCE_VALUES, } from "./constants"; +import type { CanonicalThreadDiscoveryRecord } from "./codex-app-server-client"; import { getLoopndrollDatabase } from "./db/client"; import { completionChecks, @@ -25,6 +34,8 @@ import { sessions, settings, } from "./db/schema"; +import { resolveSlackWebhookUrl, resolveTelegramBotToken } from "./secret-store"; +import { looksInternalThreadNameArtifact } from "./thread-name-artifact"; export type HookHandler = { type?: string; @@ -46,9 +57,12 @@ export type HooksDocument = { export type LoopndrollPaths = { appDirectoryPath: string; binDirectoryPath: string; + stateDirectoryPath: string; logsDirectoryPath: string; databasePath: string; managedHookPath: string; + hookRemovalWatchLockPath: string; + startupRecoveryMarkerPath: string; hookDebugLogPath: string; codexDirectoryPath: string; codexConfigPath: string; @@ -85,7 +99,12 @@ export const AWAIT_REPLY_POLL_INTERVAL_MS = 500; export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; export const TELEGRAM_NOTIFICATION_FOOTER = "Reply to this message in Telegram to continue this Codex chat."; -export const TELEGRAM_ALLOWED_UPDATES = ["message", "channel_post", "my_chat_member", "chat_member"]; +export const TELEGRAM_ALLOWED_UPDATES = [ + "message", + "channel_post", + "my_chat_member", + "chat_member", +]; export function getLoopndrollPaths(): LoopndrollPaths { const appDirectoryPath = join( @@ -99,9 +118,12 @@ export function getLoopndrollPaths(): LoopndrollPaths { return { appDirectoryPath, binDirectoryPath: join(appDirectoryPath, "bin"), + stateDirectoryPath: join(appDirectoryPath, "state"), logsDirectoryPath: join(appDirectoryPath, "logs"), databasePath: join(appDirectoryPath, "app.db"), managedHookPath: join(appDirectoryPath, "bin", "loopndroll-hook"), + hookRemovalWatchLockPath: join(appDirectoryPath, "state", "hook-removal-watch.lock"), + startupRecoveryMarkerPath: join(appDirectoryPath, "state", "startup-runtime.marker.json"), hookDebugLogPath: join(appDirectoryPath, "logs", "hooks-debug.jsonl"), codexDirectoryPath, codexConfigPath: join(codexDirectoryPath, "config.toml"), @@ -182,6 +204,88 @@ export function normalizeLoopPreset(value: unknown): LoopPreset | null { return LOOP_PRESET_VALUES.includes(value as LoopPreset) ? (value as LoopPreset) : null; } +export function normalizeLoopndrollRuntimeState(value: unknown): LoopndrollRuntimeState { + return LOOPNDROLL_RUNTIME_STATE_VALUES.includes(value as LoopndrollRuntimeState) + ? (value as LoopndrollRuntimeState) + : "running"; +} + +export function createDefaultHookLifecycleStatus(): HookLifecycleStatus { + return { + requestedAction: "none", + appliedAction: "none", + deferredAction: "none", + remainingRisk: "none", + nextAutomaticStep: null, + message: "No hook lifecycle action has been requested.", + pending: false, + checkedAt: null, + objectives: { + inertNow: false, + removedFromHooksJson: false, + unloadedFromLiveRuntime: false, + }, + }; +} + +function normalizeHookLifecycleStatus(value: unknown): HookLifecycleStatus { + const fallback = createDefaultHookLifecycleStatus(); + if (typeof value !== "object" || value === null) { + return fallback; + } + + const record = value as Partial; + const objectives = + typeof record.objectives === "object" && record.objectives !== null + ? record.objectives + : fallback.objectives; + + return { + requestedAction: HOOK_LIFECYCLE_REQUESTED_ACTION_VALUES.includes( + record.requestedAction as HookLifecycleStatus["requestedAction"], + ) + ? (record.requestedAction as HookLifecycleStatus["requestedAction"]) + : fallback.requestedAction, + appliedAction: HOOK_LIFECYCLE_APPLIED_ACTION_VALUES.includes( + record.appliedAction as HookLifecycleStatus["appliedAction"], + ) + ? (record.appliedAction as HookLifecycleStatus["appliedAction"]) + : fallback.appliedAction, + deferredAction: HOOK_LIFECYCLE_DEFERRED_ACTION_VALUES.includes( + record.deferredAction as HookLifecycleStatus["deferredAction"], + ) + ? (record.deferredAction as HookLifecycleStatus["deferredAction"]) + : fallback.deferredAction, + remainingRisk: HOOK_LIFECYCLE_RISK_VALUES.includes( + record.remainingRisk as HookLifecycleStatus["remainingRisk"], + ) + ? (record.remainingRisk as HookLifecycleStatus["remainingRisk"]) + : fallback.remainingRisk, + nextAutomaticStep: + typeof record.nextAutomaticStep === "string" ? record.nextAutomaticStep : null, + message: typeof record.message === "string" ? record.message : fallback.message, + pending: Boolean(record.pending), + checkedAt: typeof record.checkedAt === "string" ? record.checkedAt : null, + objectives: { + inertNow: Boolean(objectives.inertNow), + removedFromHooksJson: Boolean(objectives.removedFromHooksJson), + unloadedFromLiveRuntime: Boolean(objectives.unloadedFromLiveRuntime), + }, + }; +} + +export function parseHookLifecycleStatus(value: string | null | undefined): HookLifecycleStatus { + if (!value) { + return createDefaultHookLifecycleStatus(); + } + + try { + return normalizeHookLifecycleStatus(JSON.parse(value)); + } catch { + return createDefaultHookLifecycleStatus(); + } +} + export function normalizeScope(value: unknown): LoopScope { return LOOP_SCOPE_VALUES.includes(value as LoopScope) ? (value as LoopScope) : "global"; } @@ -373,11 +477,19 @@ export function createNotification(notification: CreateLoopNotificationInput): L } export function buildTelegramBotUrl(botToken: string) { - return `https://api.telegram.org/bot${botToken}/sendMessage`; + return `https://api.telegram.org/bot${resolveTelegramBotToken(botToken)}/sendMessage`; } export function buildTelegramApiUrl(botToken: string, method: string) { - return `https://api.telegram.org/bot${botToken}/${method}`; + return `https://api.telegram.org/bot${resolveTelegramBotToken(botToken)}/${method}`; +} + +export function buildTelegramBotUrlForStorage(botTokenOrRef: string) { + return `https://api.telegram.org/bot${botTokenOrRef.trim()}/sendMessage`; +} + +export function resolveSlackWebhookUrlForDelivery(webhookUrlOrRef: string) { + return resolveSlackWebhookUrl(webhookUrlOrRef); } function parseTelegramBotTokenFromUrl(botUrl: string | null) { @@ -453,18 +565,21 @@ export function notificationInsertFromValue( webhookUrl: null, chatId: notification.chatId, botToken: notification.botToken, - botUrl: buildTelegramBotUrl(notification.botToken), + botUrl: buildTelegramBotUrlForStorage(notification.botToken), chatUsername: notification.chatUsername, chatDisplayName: notification.chatDisplayName, createdAt: notification.createdAt, }; } -export function buildNewSession(sessionId: string, sessionRef: string): typeof sessions.$inferInsert { +export function buildNewSession( + threadId: string, + sessionRef: string, +): typeof sessions.$inferInsert { const timestamp = nowIsoString(); return { - sessionId, + threadId, sessionRef, source: "startup", cwd: null, @@ -477,7 +592,7 @@ export function buildNewSession(sessionId: string, sessionRef: string): typeof s presetOverridden: false, completionCheckId: null, completionCheckWaitForReply: false, - title: null, + threadName: null, transcriptPath: null, lastAssistantMessage: null, }; @@ -576,7 +691,8 @@ function mapSessionRow( ); return { - sessionId: row.sessionId, + threadId: row.threadId, + sessionId: row.threadId, sessionRef: row.sessionRef, source: LOOP_SESSION_SOURCE_VALUES.includes(row.source) ? row.source : "startup", cwd: row.cwd, @@ -593,24 +709,36 @@ function mapSessionRow( completionCheckWaitForReply: completionCheckState.completionCheckWaitForReply, effectiveCompletionCheckId: completionCheckState.effectiveCompletionCheckId, effectiveCompletionCheckWaitForReply: completionCheckState.effectiveCompletionCheckWaitForReply, - title: row.title, + threadName: row.threadName, + title: row.threadName, transcriptPath: row.transcriptPath, lastAssistantMessage: row.lastAssistantMessage, }; } -export function isPromptOnlyArtifact( - session: Pick, -) { - if (session.transcriptPath !== null) { - return false; +export function mergeCanonicalThreadDiscoveryIntoSession( + session: Pick, + discovery: CanonicalThreadDiscoveryRecord | null, +): Pick { + if (discovery === null) { + return session; } - const titleLooksInternal = session.title?.startsWith("You are a helpful assistant.") ?? false; + return { + threadId: discovery.threadId, + threadName: discovery.threadName, + cwd: discovery.cwd, + }; +} + +export function isPromptOnlyArtifact( + session: Pick, +) { + const threadNameLooksInternal = looksInternalThreadNameArtifact(session.threadName); const assistantPayloadLooksInternal = session.lastAssistantMessage?.startsWith('{"title":') ?? false; - return titleLooksInternal || assistantPayloadLooksInternal; + return threadNameLooksInternal || assistantPayloadLooksInternal; } export function getSettingsRow() { @@ -683,7 +811,7 @@ export function getStoredGlobalNotificationId(db: NotificationDefaultsReader) { export function applyGlobalNotificationToSession( tx: NotificationDefaultsWriter, - sessionId: string, + threadId: string, notificationId: string | null, ) { if (notificationId === null) { @@ -692,14 +820,14 @@ export function applyGlobalNotificationToSession( tx.insert(sessionNotifications) .values({ - sessionId, + threadId, notificationId, }) .onConflictDoNothing() .run(); } -export function readSnapshotFromDatabase() { +export function readSnapshotFromDatabase(): Omit { const { db } = getLoopndrollDatabase(getLoopndrollPaths().databasePath); const settingsRow = getSettingsRow(); const completionCheckRows = db @@ -715,12 +843,12 @@ export function readSnapshotFromDatabase() { const sessionRows = db .select() .from(sessions) - .orderBy(asc(sessions.firstSeenAt), asc(sessions.sessionId)) + .orderBy(asc(sessions.firstSeenAt), asc(sessions.threadId)) .all(); const sessionNotificationRows = db .select() .from(sessionNotifications) - .orderBy(asc(sessionNotifications.sessionId), asc(sessionNotifications.notificationId)) + .orderBy(asc(sessionNotifications.threadId), asc(sessionNotifications.notificationId)) .all(); const normalizedGlobalCompletionCheckId = normalizeGlobalCompletionCheckId( completionCheckRows.map((row) => row.id), @@ -729,18 +857,19 @@ export function readSnapshotFromDatabase() { const notificationIdMap = new Map(); for (const row of sessionNotificationRows) { - const current = notificationIdMap.get(row.sessionId); + const current = notificationIdMap.get(row.threadId); if (current) { current.push(row.notificationId); continue; } - notificationIdMap.set(row.sessionId, [row.notificationId]); + notificationIdMap.set(row.threadId, [row.notificationId]); } return { defaultPrompt: settingsRow.defaultPrompt, scope: normalizeScope(settingsRow.scope), + runtimeState: normalizeLoopndrollRuntimeState(settingsRow.runtimeState), globalPreset: normalizeLoopPreset(settingsRow.globalPreset), globalNotificationId: normalizeGlobalNotificationId( notificationRows.map((row) => row.id), @@ -749,13 +878,15 @@ export function readSnapshotFromDatabase() { globalCompletionCheckId: normalizedGlobalCompletionCheckId, globalCompletionCheckWaitForReply: settingsRow.globalCompletionCheckWaitForReply, hooksAutoRegistration: settingsRow.hooksAutoRegistration, + mirrorEnabled: settingsRow.mirrorEnabled, notifications: notificationRows.map(mapNotificationRow), completionChecks: completionCheckRows.map(mapCompletionCheckRow), + hookLifecycle: parseHookLifecycleStatus(settingsRow.hookLifecycleStatusJson), sessions: sessionRows .map((row) => mapSessionRow( row, - notificationIdMap.get(row.sessionId) ?? [], + notificationIdMap.get(row.threadId) ?? [], normalizeLoopPreset(settingsRow.globalPreset), normalizedGlobalCompletionCheckId, settingsRow.globalCompletionCheckWaitForReply, diff --git a/src/bun/loopndroll.ts b/src/bun/loopndroll.ts index 75bb439..78b2ce1 100644 --- a/src/bun/loopndroll.ts +++ b/src/bun/loopndroll.ts @@ -1,11 +1,17 @@ export { getTelegramChats } from "./telegram-utils"; export { startLoopndrollTelegramBridge } from "./telegram-bridge"; +export { clearStartupRecoveryMarker, resetActiveLoopStateOnStartup } from "./startup-recovery"; export { clearHooks, ensureLoopndrollSetup, getLoopndrollSnapshot, + pauseLoopndroll, registerHooks, revealHooksFile, + resumeLoopndroll, + startLoopndroll, + startHookRemovalPendingMonitor, + stopLoopndroll, } from "./hook-management"; export { createCompletionCheck, @@ -13,10 +19,12 @@ export { deleteCompletionCheck, deleteLoopNotification, deleteSession, + migrateNotificationSecretsToKeychain, saveDefaultPrompt, setGlobalCompletionCheckConfig, setGlobalNotification, setGlobalPreset, + setMirrorEnabled, setLoopScope, setSessionArchived, setSessionCompletionCheckConfig, diff --git a/src/bun/managed-hook-script.test.ts b/src/bun/managed-hook-script.test.ts new file mode 100644 index 0000000..95fb212 --- /dev/null +++ b/src/bun/managed-hook-script.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; + +import { buildManagedHookScript, normalizeRuntimeStateHelperName } from "./managed-hook-script"; +import { MANAGED_HOOK_SCRIPT_CHUNK_3 } from "./managed-hook-script/chunk-3"; + +function createTestPaths() { + return { + appDirectoryPath: "/tmp/app", + binDirectoryPath: "/tmp/app/bin", + stateDirectoryPath: "/tmp/app/state", + logsDirectoryPath: "/tmp/app/logs", + databasePath: "/tmp/app/app.db", + managedHookPath: "/tmp/app/bin/loopndroll-hook", + hookRemovalWatchLockPath: "/tmp/app/state/hook-removal-watch.lock", + startupRecoveryMarkerPath: "/tmp/app/state/startup-runtime.marker.json", + hookDebugLogPath: "/tmp/app/logs/hooks-debug.jsonl", + codexDirectoryPath: "/tmp/.codex", + codexConfigPath: "/tmp/.codex/config.toml", + codexHooksPath: "/tmp/.codex/hooks.json", + }; +} + +describe("MANAGED_HOOK_SCRIPT_CHUNK_3", () => { + test("embeds the runtime-state guard directly in the generated source", () => { + const script = MANAGED_HOOK_SCRIPT_CHUNK_3; + + expect(script).toContain("const runtimeState = getLoopndrollRuntimeState(db);"); + expect(script).toContain('if (runtimeState !== "running") {'); + expect(script).toContain("reason: `runtime-${runtimeState}`"); + expect(script).toContain("sessionId: input.session_id"); + }); +}); + +describe("buildManagedHookScript", () => { + test("normalizes bundled runtime-state helper names back to the chunk contract", () => { + const source = "function getLoopndrollRuntimeState2(db) { return 'running'; }"; + + expect(normalizeRuntimeStateHelperName(source)).toBe( + "function getLoopndrollRuntimeState(db) { return 'running'; }", + ); + }); + + test("defines the exact runtime-state helper name used by the hook body", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("function getLoopndrollRuntimeState(db)"); + expect(script).toContain("const runtimeState = getLoopndrollRuntimeState(db);"); + expect(script).not.toContain("function getLoopndrollRuntimeState2("); + }); + + test("does not emit escaped backticks into the hook source", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).not.toContain("\\`"); + }); + + test("includes the telegram output helpers needed at runtime", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("function compactWhitespace(value)"); + expect(script).toContain("function appendTelegramChunkLabel(header, chunkLabel)"); + expect(script).toContain("function buildTelegramNotificationChunks(input)"); + }); + + test("does not expose passive mode in the generated v1 hook", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).not.toContain('value === "passive"'); + expect(script).not.toContain('preset === "passive"'); + expect(script).not.toContain("queue the next prompt for this Codex chat"); + }); + + test("resolves Telegram keychain token references without embedding real bot tokens", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("keychain://loopndroll/telegram-bot-token/"); + expect(script).toContain("find-generic-password"); + expect(script).toContain("resolveTelegramBotToken(botToken)"); + expect(script).not.toContain("opaque-token-value"); + }); + + test("resolves Slack keychain webhook references before delivery", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("keychain://loopndroll/slack-webhook-url/"); + expect(script).toContain("resolveSlackWebhookUrl(notification.webhook_url)"); + expect(script).not.toContain("fetch(notification.webhook_url"); + }); + + test("targets current thread_id schema while preserving Codex session_id input", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("input.session_id"); + expect(script).toContain("thread_id as session_id"); + expect(script).toContain("thread_name as title"); + expect(script).toContain("where thread_id = ?"); + expect(script).toContain("where s.thread_id = ?"); + expect(script).toContain("where sn.thread_id = ?"); + expect(script).toContain("update sessions set thread_name = ? where thread_id = ?"); + expect(script).not.toContain("where session_id = ?"); + expect(script).not.toContain("where s.session_id = ?"); + expect(script).not.toContain("where sn.session_id = ?"); + expect(script).not.toContain("select session_ref, title, archived, cwd"); + expect(script).not.toContain("update sessions set title = ?"); + expect(script).not.toContain("insert into sessions (\\n session_id,"); + }); +}); + +describe("buildManagedHookScript runtime behavior", () => { + test("finalizes hook SQLite statements before closing the process-scoped connection", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("const db = new Database(databasePath, { create: true });"); + expect(script).toContain("function installHookSqliteStatementFinalizer"); + expect(script).toContain("const finalizeHookSqliteStatements"); + expect(script).toContain("finalizeHookSqliteStatements();"); + expect(script).toContain("db.close();"); + expect(script).not.toContain("db?.close();"); + }); + + test("sends working acknowledgement only after consuming a Telegram prompt", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("async function sendTelegramWorkingAck"); + expect(script).toContain("buildTelegramWorkingAckText({"); + expect(script).toContain("await sendTelegramWorkingAck(db, sessionId, telegramTargets);"); + expect(script).toContain('type: "telegram-working-ack"'); + }); + + test("does not send stop notifications when the thread has no active mode", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("if (effectivePreset === null && !settingsRow.mirror_enabled) {"); + expect(script).toContain('reason: "no-active-mode-and-mirror-disabled"'); + expect(script).toContain("return [];"); + }); + + test("mirrors user prompts only when mirror mode is enabled", () => { + const script = buildManagedHookScript(createTestPaths()); + + expect(script).toContain("async function sendUserPromptMirrorNotifications"); + expect(script).toContain("if (!settingsRow.mirror_enabled) {"); + expect(script).toContain("await sendUserPromptMirrorNotifications(db, input);"); + expect(script).toContain("message: `User Message:\\n\\n${message}`"); + expect(script).toContain("Slack user mirror failed with status"); + }); +}); diff --git a/src/bun/managed-hook-script.ts b/src/bun/managed-hook-script.ts index 9d5e7d5..f9d0587 100644 --- a/src/bun/managed-hook-script.ts +++ b/src/bun/managed-hook-script.ts @@ -15,6 +15,369 @@ import { type LoopndrollPaths, MANAGED_HOOK_SCRIPT_MARKER, } from "./loopndroll-core"; +import { + buildTelegramPromptReceivedText, + buildTelegramWorkingAckText, + getTelegramRemotePromptDeliveryMode, +} from "./telegram-control"; +import { TELEGRAM_OUTPUT_HOOK_SOURCE } from "./telegram-output"; + +const TELEGRAM_WORKING_ACK_HELPER_SOURCE = ` +async function sendTelegramWorkingAck(db, sessionId, telegramTargets) { + if (!Array.isArray(telegramTargets) || telegramTargets.length === 0) { + return; + } + + const session = getSession(db, sessionId); + if (!session) { + return; + } + + const text = buildTelegramWorkingAckText({ + cwd: session.cwd, + sessionRef: session.sessionRef, + title: session.title, + }); + const seenTargets = new Set(); + const results = await Promise.allSettled( + telegramTargets.map(async (target) => { + const botToken = typeof target?.botToken === "string" ? target.botToken.trim() : ""; + const chatId = typeof target?.chatId === "string" ? target.chatId.trim() : ""; + const dedupeKey = \`\${botToken}::\${chatId}\`; + if (botToken.length === 0 || chatId.length === 0 || seenTargets.has(dedupeKey)) { + return; + } + + seenTargets.add(dedupeKey); + const response = await fetch(buildTelegramBotUrl(botToken), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body: new URLSearchParams({ + chat_id: chatId, + text, + }).toString(), + }); + if (!response.ok) { + throw new Error(\`Telegram working acknowledgement failed with status \${response.status}\`); + } + }), + ); + + const failures = results.filter((result) => result.status === "rejected").length; + await appendHookDebugLog({ + type: "telegram-working-ack", + sessionId, + deliveredCount: results.length - failures, + failedCount: failures, + }); +} +`; + +const TELEGRAM_USER_MIRROR_HELPER_SOURCE = ` +async function sendUserPromptMirrorNotifications(db, input) { + const settingsRow = getSettings(db); + if (!settingsRow.mirror_enabled) { + return []; + } + + const message = typeof input.prompt === "string" ? input.prompt.trim() : ""; + if (message.length === 0) { + return []; + } + + const selectedNotifications = db + .query( + \`select + n.id, + n.label, + n.channel, + n.webhook_url, + n.chat_id, + n.bot_token, + n.bot_url, + n.created_at + from notifications n + inner join session_notifications sn on sn.notification_id = n.id + where sn.session_id = ? + order by n.created_at asc, n.id asc\`, + ) + .all(input.session_id); + if (selectedNotifications.length === 0) { + return []; + } + + const sessionRow = db + .query("select session_ref, title, archived, cwd from sessions where session_id = ?") + .get(input.session_id); + if (Boolean(sessionRow?.archived)) { + return []; + } + + const mirrorTexts = buildTelegramNotificationChunks({ + cwd: sessionRow?.cwd ?? null, + sessionRef: sessionRow?.session_ref ?? null, + sessionTitle: sessionRow?.title ?? null, + message: \`User Message:\\n\\n\${message}\`, + preset: null, + telegramNotificationFooter, + maxLength: telegramMaxMessageLength, + }); + + const results = await Promise.allSettled( + selectedNotifications.map(async (notification) => { + if (notification.channel === "slack") { + const response = await fetch(resolveSlackWebhookUrl(notification.webhook_url), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ text: \`User Message:\\n\\n\${message}\` }), + }); + if (!response.ok) { + throw new Error(\`Slack user mirror failed with status \${response.status}\`); + } + return; + } + + const telegramEndpoint = + (typeof notification.bot_token === "string" && notification.bot_token.length > 0 + ? buildTelegramBotUrl(notification.bot_token) + : notification.bot_url) ?? null; + if (!telegramEndpoint) { + throw new Error("Telegram notification is missing a bot token."); + } + + for (const telegramText of mirrorTexts) { + const response = await fetch(telegramEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body: new URLSearchParams({ + chat_id: notification.chat_id, + text: telegramText, + }).toString(), + }); + if (!response.ok) { + throw new Error(\`Telegram user mirror failed with status \${response.status}\`); + } + } + }), + ); + + const failures = results.filter((result) => result.status === "rejected").length; + await appendHookDebugLog({ + type: "telegram-mirror", + hookEventName: "UserPromptSubmit", + sessionId: input.session_id, + deliveredCount: results.length - failures, + failedCount: failures, + }); + + return results; +} +`; + +const SQLITE_STATEMENT_FINALIZER_SOURCE = ` +function installHookSqliteStatementFinalizer(db) { + const statements = []; + const originalQuery = db.query.bind(db); + db.query = (...args) => { + const statement = originalQuery(...args); + statements.push(statement); + return statement; + }; + + return () => { + for (const statement of statements.reverse()) { + try { + statement.finalize(); + } catch { + // The hook is process-scoped; finalization is best-effort cleanup before close. + } + } + }; +} +`; + +function getLoopndrollRuntimeState(db: { + query: (sql: string) => { get: (...args: unknown[]) => Record | null }; +}) { + const row = db.query("select runtime_state from settings where id = 1").get(); + return row?.runtime_state === "paused" || row?.runtime_state === "stopped" + ? row.runtime_state + : "running"; +} + +export function normalizeRuntimeStateHelperName(source: string) { + return source.replace( + /^function getLoopndrollRuntimeState\d*\(/, + "function getLoopndrollRuntimeState(", + ); +} + +function normalizeManagedHookTelegramTokenResolver(source: string) { + return source.replaceAll( + [ + "function buildTelegramBotUrl(botToken) {", + " return `https://api.telegram.org/bot${botToken}/sendMessage`;", + "}", + ].join("\n"), + [ + 'const telegramBotTokenKeychainRefPrefix = "keychain://loopndroll/telegram-bot-token/";', + 'const telegramBotTokenKeychainService = "loopndroll.telegram.bot-token";', + 'const slackWebhookUrlKeychainRefPrefix = "keychain://loopndroll/slack-webhook-url/";', + 'const slackWebhookUrlKeychainService = "loopndroll.slack.webhook-url";', + "", + "function resolveKeychainSecret(secretOrRef, refPrefix, service, label) {", + ' const value = String(secretOrRef ?? "").trim();', + " if (!value.startsWith(refPrefix)) {", + " return value;", + " }", + " const account = decodeURIComponent(value.slice(refPrefix.length));", + " const result = spawnSync(", + ' "/usr/bin/security",', + " [", + ' "find-generic-password",', + ' "-a",', + " account,", + ' "-s",', + " service,", + ' "-w",', + " ],", + ' { encoding: "utf8", maxBuffer: 1024 * 1024 },', + " );", + " if (result.status !== 0) {", + " throw new Error(`Could not read ${label} from macOS Keychain.`);", + " }", + ' const secret = String(result.stdout ?? "").trim();', + " if (secret.length === 0) {", + " throw new Error(`${label} in macOS Keychain is empty.`);", + " }", + " return secret;", + "}", + "", + "function resolveTelegramBotToken(botTokenOrRef) {", + " return resolveKeychainSecret(", + " botTokenOrRef,", + " telegramBotTokenKeychainRefPrefix,", + " telegramBotTokenKeychainService,", + ' "Telegram bot token",', + " );", + "}", + "", + "function resolveSlackWebhookUrl(webhookUrlOrRef) {", + " return resolveKeychainSecret(", + " webhookUrlOrRef,", + " slackWebhookUrlKeychainRefPrefix,", + " slackWebhookUrlKeychainService,", + ' "Slack webhook URL",', + " );", + "}", + "", + "function buildTelegramBotUrl(botToken) {", + " return `https://api.telegram.org/bot${resolveTelegramBotToken(botToken)}/sendMessage`;", + "}", + ].join("\n"), + ); +} + +function normalizeManagedHookSchemaReferences(source: string) { + return normalizeManagedHookTelegramTokenResolver(source) + .replaceAll( + " const db = new Database(databasePath, { create: true });\n configureDatabase(db);\n applyMigrations(db);\n\n const sessionCountBefore", + " const db = new Database(databasePath, { create: true });\n const finalizeHookSqliteStatements = installHookSqliteStatementFinalizer(db);\n try {\n configureDatabase(db);\n applyMigrations(db);\n\n const sessionCountBefore", + ) + .replaceAll( + "\n}\n\nawait main().catch(async (error) => {", + "\n } finally {\n finalizeHookSqliteStatements();\n db.close();\n }\n}\n\nawait main().catch(async (error) => {", + ) + .replaceAll( + "if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n return {", + "if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n await sendTelegramWorkingAck(db, sessionId, telegramTargets);\n return {", + ) + .replaceAll( + 'if (resolution?.type === "prompt") {\n return {', + 'if (resolution?.type === "prompt") {\n await sendTelegramWorkingAck(db, sessionId, telegramTargets);\n return {', + ) + .replaceAll( + " const effectivePreset = getEffectivePreset(db, input.session_id);\n const telegramTexts = buildTelegramNotificationChunks({", + ' const effectivePreset = getEffectivePreset(db, input.session_id);\n const settingsRow = getSettings(db);\n if (effectivePreset === null && !settingsRow.mirror_enabled) {\n await appendHookDebugLog({\n type: "notification",\n hookEventName: "Stop",\n sessionId: input.session_id,\n action: "skipped",\n reason: "no-active-mode-and-mirror-disabled",\n });\n return [];\n }\n const telegramTexts = buildTelegramNotificationChunks({', + ) + .replaceAll( + ' await appendHookDebugLog({\n type: "hook-event",\n hookEventName,\n action: existingSession ? "update-session-title" : "recover-session-on-prompt",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n });\n return;', + ' await sendUserPromptMirrorNotifications(db, input);\n await appendHookDebugLog({\n type: "hook-event",\n hookEventName,\n action: existingSession ? "update-session-title" : "recover-session-on-prompt",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n });\n return;', + ) + .replaceAll( + "select default_prompt, scope, global_preset, global_notification_id, global_completion_check_id, global_completion_check_wait_for_reply, hooks_auto_registration from settings where id = 1", + "select default_prompt, scope, global_preset, global_notification_id, global_completion_check_id, global_completion_check_wait_for_reply, hooks_auto_registration, mirror_enabled from settings where id = 1", + ) + .replaceAll( + " const remotePrompt =\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSessionRemotePrompt(db, sessionId);\n return {", + " const remotePrompt =\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSessionRemotePrompt(db, sessionId);\n if (remotePrompt) {\n await sendTelegramWorkingAck(db, sessionId, telegramTargets);\n }\n return {", + ) + .replaceAll( + "const response = await fetch(notification.webhook_url, {", + "const response = await fetch(resolveSlackWebhookUrl(notification.webhook_url), {", + ) + .replaceAll( + "insert into sessions (\n session_id,", + "insert into sessions (\n thread_id,", + ) + .replaceAll("select\n session_id,", "select\n thread_id as session_id,") + .replaceAll("\n title = ?,", "\n thread_name = ?,") + .replaceAll( + "\n title,\n transcript_path", + "\n thread_name,\n transcript_path", + ) + .replaceAll( + "\n title,\n transcript_path", + "\n thread_name as title,\n transcript_path", + ) + .replaceAll( + "update sessions set title = ? where thread_id = ?", + "update sessions set thread_name = ? where thread_id = ?", + ) + .replaceAll( + "select session_ref, title, archived, cwd from sessions where thread_id = ?", + "select session_ref, thread_name as title, archived, cwd from sessions where thread_id = ?", + ) + .replaceAll("where s.session_id = ?", "where s.thread_id = ?") + .replaceAll("where sn.session_id = ?", "where sn.thread_id = ?") + .replaceAll("where session_id != ?", "where thread_id != ?") + .replaceAll("where session_id = ?", "where thread_id = ?") + .replaceAll( + "update sessions set title = ? where thread_id = ?", + "update sessions set thread_name = ? where thread_id = ?", + ) + .replaceAll( + "select session_ref, title, archived, cwd from sessions where thread_id = ?", + "select session_ref, thread_name as title, archived, cwd from sessions where thread_id = ?", + ) + .replaceAll( + "insert into session_notifications (session_id, notification_id)", + "insert into session_notifications (thread_id, notification_id)", + ) + .replaceAll( + "on conflict(session_id, notification_id)", + "on conflict(thread_id, notification_id)", + ) + .replaceAll( + "insert into session_runtime (session_id, remaining_turns)", + "insert into session_runtime (thread_id, remaining_turns)", + ) + .replaceAll("on conflict(session_id) do update", "on conflict(thread_id) do update") + .replaceAll( + "insert into session_awaiting_replies (\n session_id,", + "insert into session_awaiting_replies (\n thread_id,", + ) + .replaceAll( + "insert into telegram_delivery_receipts (\n id,\n notification_id,\n session_id,", + "insert into telegram_delivery_receipts (\n id,\n notification_id,\n thread_id,", + ); +} export function buildManagedHookScript(paths: LoopndrollPaths) { const preamble = `#!/usr/bin/env bun @@ -39,10 +402,21 @@ const sqlitePragmas = ${JSON.stringify([...SQLITE_PRAGMA_STATEMENTS])}; const appMigrations = ${JSON.stringify(appMigrations)}; `; - return [ - preamble, - MANAGED_HOOK_SCRIPT_CHUNK_1, - MANAGED_HOOK_SCRIPT_CHUNK_2, - MANAGED_HOOK_SCRIPT_CHUNK_3, - ].join(""); + const hookBody = normalizeManagedHookSchemaReferences( + [ + `${TELEGRAM_OUTPUT_HOOK_SOURCE}\n`, + `${normalizeRuntimeStateHelperName(getLoopndrollRuntimeState.toString())}\n\n`, + `${getTelegramRemotePromptDeliveryMode.toString()}\n\n`, + `${buildTelegramPromptReceivedText.toString()}\n\n`, + `${buildTelegramWorkingAckText.toString()}\n\n`, + `${TELEGRAM_WORKING_ACK_HELPER_SOURCE}\n`, + `${TELEGRAM_USER_MIRROR_HELPER_SOURCE}\n`, + `${SQLITE_STATEMENT_FINALIZER_SOURCE}\n`, + MANAGED_HOOK_SCRIPT_CHUNK_1, + MANAGED_HOOK_SCRIPT_CHUNK_2, + MANAGED_HOOK_SCRIPT_CHUNK_3, + ].join(""), + ); + + return `${preamble}${hookBody}`; } diff --git a/src/bun/managed-hook-script/chunk-1.ts b/src/bun/managed-hook-script/chunk-1.ts index f64e2fb..78ff2bc 100644 --- a/src/bun/managed-hook-script/chunk-1.ts +++ b/src/bun/managed-hook-script/chunk-1.ts @@ -1 +1,2 @@ -export const MANAGED_HOOK_SCRIPT_CHUNK_1 = "function nowIsoString() {\n return new Date().toISOString();\n}\n\nfunction isTruthyEnvValue(value) {\n if (!value) {\n return false;\n }\n\n return [\"1\", \"true\", \"yes\", \"on\"].includes(String(value).trim().toLowerCase());\n}\n\nfunction normalizeLoopPreset(value) {\n return value === \"infinite\" ||\n value === \"await-reply\" ||\n value === \"completion-checks\" ||\n value === \"max-turns-1\" ||\n value === \"max-turns-2\" ||\n value === \"max-turns-3\"\n ? value\n : null;\n}\n\nfunction resolveSessionPresetState(sessionPresetValue, presetOverriddenValue, globalPresetValue) {\n const preset = normalizeLoopPreset(sessionPresetValue);\n const presetOverridden = Boolean(presetOverriddenValue);\n const globalPreset = normalizeLoopPreset(globalPresetValue);\n\n if (preset !== null) {\n return {\n preset,\n presetSource: \"session\",\n effectivePreset: preset,\n };\n }\n\n if (presetOverridden) {\n return {\n preset: null,\n presetSource: \"off\",\n effectivePreset: null,\n };\n }\n\n return {\n preset: null,\n presetSource: \"global\",\n effectivePreset: globalPreset,\n };\n}\n\nfunction isSqliteBusyError(error) {\n return error instanceof Error && /SQLITE_BUSY|database is locked/i.test(error.message);\n}\n\nfunction sleepSync(milliseconds) {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);\n}\n\nfunction withSqliteBusyRetry(operation, maxAttempts = 5, delayMs = 25) {\n for (let attempt = 1; ; attempt += 1) {\n try {\n return operation();\n } catch (error) {\n if (!isSqliteBusyError(error) || attempt >= maxAttempts) {\n throw error;\n }\n\n sleepSync(delayMs);\n }\n }\n}\n\nfunction truncateTelegramText(text, maxLength) {\n if (text.length <= maxLength) {\n return text;\n }\n\n return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n\nfunction buildTelegramNotificationText(sessionRef, sessionTitle, message, preset) {\n const headerParts = [];\n if (typeof sessionRef === \"string\" && sessionRef.trim().length > 0) {\n headerParts.push(`[${sessionRef.trim()}]`);\n }\n if (typeof sessionTitle === \"string\" && sessionTitle.trim().length > 0) {\n headerParts.push(sessionTitle.trim());\n }\n\n const header = headerParts.join(\" - \");\n const segments = [header, String(message ?? \"\").trim()].filter(\n (segment) => typeof segment === \"string\" && segment.length > 0,\n );\n\n const replyCommandFooter =\n typeof sessionRef === \"string\" && sessionRef.trim().length > 0\n ? `Or send /reply ${sessionRef.trim()} your message.`\n : null;\n\n if (preset === \"await-reply\" || preset === \"completion-checks\") {\n segments.push(\"---------\", telegramNotificationFooter);\n if (replyCommandFooter) {\n segments.push(replyCommandFooter);\n }\n } else if (preset === \"infinite\" || preset === \"max-turns-1\" || preset === \"max-turns-2\" || preset === \"max-turns-3\") {\n segments.push(\n \"---------\",\n \"Reply to this message in Telegram to replace the prompt that will keep being sent to this Codex chat.\",\n );\n if (replyCommandFooter) {\n segments.push(replyCommandFooter);\n }\n }\n\n return truncateTelegramText(segments.join(\"\\n\\n\"), telegramMaxMessageLength);\n}\n\nfunction buildTelegramBotUrl(botToken) {\n return `https://api.telegram.org/bot${botToken}/sendMessage`;\n}\n\nasync function ensureDirectory(path) {\n await mkdir(path, { recursive: true });\n}\n\nfunction shouldEnableHookDebugLogging() {\n return isTruthyEnvValue(process.env[hookDebugLogEnvName]);\n}\n\nfunction sanitizeHookDebugLogValue(value, seen = new WeakSet()) {\n if (value == null || typeof value === \"boolean\" || typeof value === \"number\") {\n return value;\n }\n\n if (typeof value === \"string\") {\n return value;\n }\n\n if (Array.isArray(value)) {\n return value.map((item) => sanitizeHookDebugLogValue(item, seen));\n }\n\n if (typeof value !== \"object\") {\n return String(value);\n }\n\n if (seen.has(value)) {\n return \"[circular]\";\n }\n\n seen.add(value);\n\n return Object.fromEntries(\n Object.entries(value).map(([entryKey, entryValue]) => {\n if (\n hookDebugRedactedKeys.includes(entryKey) ||\n /(token|secret|password)$/i.test(entryKey)\n ) {\n return [entryKey, redactedDebugValue];\n }\n\n return [entryKey, sanitizeHookDebugLogValue(entryValue, seen)];\n }),\n );\n}\n\nasync function appendHookDebugLog(entry) {\n if (!shouldEnableHookDebugLogging()) {\n return;\n }\n\n await ensureDirectory(logsDirectoryPath);\n await appendFile(\n hookDebugLogPath,\n `${JSON.stringify(\n sanitizeHookDebugLogValue({\n timestamp: nowIsoString(),\n ...entry,\n }),\n )}\\n`,\n \"utf8\",\n );\n}\n\nfunction configureDatabase(db) {\n for (const statement of sqlitePragmas) {\n db.exec(statement);\n }\n}\n\nfunction shouldIgnoreMigrationStatementError(db, statement, error) {\n const message = error instanceof Error ? error.message : String(error);\n if (!message.toLowerCase().includes(\"duplicate column name:\")) {\n return false;\n }\n\n const match = /^\\s*alter\\s+table\\s+(\\w+)\\s+add\\s+column\\s+(\\w+)/i.exec(statement);\n if (!match) {\n return false;\n }\n\n const [, tableName, columnName] = match;\n const rows = db.query(`pragma table_info(${tableName})`).all();\n return rows.some((row) => row.name === columnName);\n}\n\nfunction applyMigrations(db) {\n db.exec(`create table if not exists schema_migrations (\n id integer primary key,\n name text not null,\n applied_at text not null\n )`);\n\n const appliedRows = db.query(\"select id from schema_migrations order by id asc\").all();\n const appliedIds = new Set(appliedRows.map((row) => row.id));\n const insertMigration = db.query(\n \"insert into schema_migrations (id, name, applied_at) values (?, ?, ?)\",\n );\n const applyMigration = db.transaction((migration) => {\n for (const statement of migration.statements) {\n try {\n db.exec(statement);\n } catch (error) {\n if (shouldIgnoreMigrationStatementError(db, statement, error)) {\n continue;\n }\n\n throw error;\n }\n }\n\n insertMigration.run(migration.id, migration.name, nowIsoString());\n });\n\n for (const migration of appMigrations) {\n if (appliedIds.has(migration.id)) {\n continue;\n }\n\n applyMigration(migration);\n }\n}\n\nfunction getSettings(db) {\n const row = db\n .query(\n \"select default_prompt, scope, global_preset, global_notification_id, global_completion_check_id, global_completion_check_wait_for_reply, hooks_auto_registration from settings where id = 1\",\n )\n .get();\n if (!row) {\n throw new Error(\"Loopndroll settings row is missing.\");\n }\n\n return row;\n}\n\nfunction getValidGlobalNotificationId(db, candidate) {\n if (typeof candidate !== \"string\") {\n return null;\n }\n\n const notificationId = candidate.trim();\n if (notificationId.length === 0) {\n return null;\n }\n\n const existingNotification = db\n .query(\"select id from notifications where id = ?\")\n .get(notificationId);\n\n return existingNotification ? notificationId : null;\n}\n\nfunction getValidGlobalCompletionCheckId(db, candidate) {\n if (typeof candidate !== \"string\") {\n return null;\n }\n\n const completionCheckId = candidate.trim();\n if (completionCheckId.length === 0) {\n return null;\n }\n\n const existingCompletionCheck = db\n .query(\"select id from completion_checks where id = ?\")\n .get(completionCheckId);\n\n return existingCompletionCheck ? completionCheckId : null;\n}\n\nfunction parseCompletionCheckCommands(commandsJson) {\n try {\n const parsed = JSON.parse(commandsJson);\n return Array.isArray(parsed)\n ? parsed.map((command) => String(command).trim()).filter((command) => command.length > 0)\n : [];\n } catch {\n return [];\n }\n}\n\nfunction getActiveCompletionCheckForSession(db, sessionId) {\n const row = db\n .query(\n `select\n s.preset as session_preset,\n s.preset_overridden as preset_overridden,\n s.completion_check_id as session_completion_check_id,\n s.completion_check_wait_for_reply as session_completion_check_wait_for_reply,\n st.global_preset as global_preset,\n st.global_completion_check_id as global_completion_check_id,\n st.global_completion_check_wait_for_reply as global_completion_check_wait_for_reply\n from sessions s\n left join settings st on st.id = 1\n where s.session_id = ?\n limit 1`,\n )\n .get(sessionId);\n if (!row) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n const presetState = resolveSessionPresetState(\n row.session_preset,\n row.preset_overridden,\n row.global_preset,\n );\n const usesSessionConfig = presetState.presetSource === \"session\";\n const completionCheckId = getValidGlobalCompletionCheckId(\n db,\n usesSessionConfig ? row.session_completion_check_id : row.global_completion_check_id,\n );\n if (completionCheckId === null) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n const completionCheckRow = db\n .query(\n \"select id, label, commands_json from completion_checks where id = ? limit 1\",\n )\n .get(completionCheckId);\n if (!completionCheckRow) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n return {\n completionCheck: {\n id: completionCheckRow.id,\n label: completionCheckRow.label,\n commands: parseCompletionCheckCommands(completionCheckRow.commands_json),\n },\n waitForReplyAfterCompletion: usesSessionConfig\n ? Boolean(row.session_completion_check_wait_for_reply)\n : Boolean(row.global_completion_check_wait_for_reply),\n };\n}\n\nfunction summarizeCompletionCheckOutput(output) {\n const normalizedOutput = String(output ?? \"\").trim();\n if (normalizedOutput.length === 0) {\n return null;\n }\n\n const lines = normalizedOutput.split(/\\r?\\n/).map((line) => line.trimEnd()).filter(Boolean);\n const tail = lines.slice(-8).join(\"\\n\").trim();\n return tail.length > 0 ? tail : null;\n}\n\nfunction runCompletionCheckCommands(input, completionCheck) {\n const cwd = typeof input?.cwd === \"string\" && input.cwd.trim().length > 0 ? input.cwd : null;\n if (cwd === null) {\n return {\n status: \"skipped\",\n };\n }\n\n if (!completionCheck || !Array.isArray(completionCheck.commands) || completionCheck.commands.length === 0) {\n return {\n status: \"skipped\",\n };\n }\n\n for (const command of completionCheck.commands) {\n const result = spawnSync(\"/bin/sh\", [\"-lc\", command], {\n cwd,\n encoding: \"utf8\",\n maxBuffer: 10 * 1024 * 1024,\n });\n const combinedOutput = [result.stdout, result.stderr].filter(Boolean).join(\"\\n\").trim();\n\n if (result.error) {\n const outputSummary = summarizeCompletionCheckOutput(combinedOutput);\n return {\n status: \"failed\",\n reason: [\n \"Completion check failed while running:\",\n command,\n outputSummary ? \"\" : \"The command exited before completion.\",\n outputSummary ? `Recent output:\\n${outputSummary}` : null,\n \"\",\n \"Fix issues.\",\n ]\n .filter(Boolean)\n .join(\"\\n\"),\n };\n }\n\n if (result.status !== 0) {\n const outputSummary = summarizeCompletionCheckOutput(combinedOutput);\n return {\n status: \"failed\",\n reason: [\n \"Completion check failed while running:\",\n command,\n `Exit code: ${result.status}`,\n outputSummary ? `Recent output:\\n${outputSummary}` : null,\n \"\",\n \"Fix issues.\",\n ]\n .filter(Boolean)\n .join(\"\\n\"),\n };\n }\n }\n\n return {\n status: \"passed\",\n };\n}\n\nfunction applyGlobalNotificationToSession(db, sessionId, notificationId) {\n if (notificationId === null) {\n return;\n }\n\n withSqliteBusyRetry(() =>\n db\n .query(\n `insert into session_notifications (session_id, notification_id)\n values (?, ?)\n on conflict(session_id, notification_id) do nothing`,\n )\n .run(sessionId, notificationId),\n );\n}\n\nfunction allocateNextSessionRef(db) {\n const allocate = db.transaction(() => {\n const row = db.query(\"select last_value from session_ref_sequence where id = 1\").get();\n const nextValue = (typeof row?.last_value === \"number\" ? row.last_value : 0) + 1;\n\n db.query(\n `insert into session_ref_sequence (id, last_value)\n values (1, ?)\n on conflict(id) do update set last_value = excluded.last_value`,\n ).run(nextValue);\n\n return `C${nextValue}`;\n });\n\n return withSqliteBusyRetry(() => allocate());\n}\n\nfunction buildNewSession(db, sessionId) {\n const timestamp = nowIsoString();\n return {\n sessionId,\n sessionRef: allocateNextSessionRef(db),\n source: \"startup\",\n cwd: null,\n archived: false,\n firstSeenAt: timestamp,\n lastSeenAt: timestamp,\n activeSince: null,\n stopCount: 0,\n preset: null,\n presetOverridden: false,\n title: null,\n transcriptPath: null,\n lastAssistantMessage: null,\n };\n}\n\nfunction getSession(db, sessionId) {\n const row = db\n .query(\n `select\n session_id,\n session_ref,\n source,\n cwd,\n archived,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n preset_overridden,\n title,\n transcript_path,\n last_assistant_message\n from sessions\n where session_id = ?`,\n )\n .get(sessionId);\n\n if (!row) {\n return null;\n }\n\n return {\n sessionId: row.session_id,\n sessionRef: row.session_ref,\n source: row.source,\n cwd: row.cwd,\n archived: Boolean(row.archived),\n firstSeenAt: row.first_seen_at,\n lastSeenAt: row.last_seen_at,\n activeSince: row.active_since,\n stopCount: row.stop_count,\n preset: row.preset,\n presetOverridden: Boolean(row.preset_overridden),\n title: row.title,\n transcriptPath: row.transcript_path,\n lastAssistantMessage: row.last_assistant_message,\n };\n}\n\nfunction writeSession(db, session, existing) {\n if (existing) {\n withSqliteBusyRetry(() =>\n db\n .query(\n `update sessions\n set session_ref = ?,\n source = ?,\n cwd = ?,\n archived = ?,\n first_seen_at = ?,\n last_seen_at = ?,\n"; +export const MANAGED_HOOK_SCRIPT_CHUNK_1 = + 'function nowIsoString() {\n return new Date().toISOString();\n}\n\nfunction isTruthyEnvValue(value) {\n if (!value) {\n return false;\n }\n\n return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());\n}\n\nfunction normalizeLoopPreset(value) {\n return value === "infinite" ||\n value === "await-reply" ||\n value === "completion-checks" ||\n value === "max-turns-1" ||\n value === "max-turns-2" ||\n value === "max-turns-3"\n ? value\n : null;\n}\n\nfunction resolveSessionPresetState(sessionPresetValue, presetOverriddenValue, globalPresetValue) {\n const preset = normalizeLoopPreset(sessionPresetValue);\n const presetOverridden = Boolean(presetOverriddenValue);\n const globalPreset = normalizeLoopPreset(globalPresetValue);\n\n if (preset !== null) {\n return {\n preset,\n presetSource: "session",\n effectivePreset: preset,\n };\n }\n\n if (presetOverridden) {\n return {\n preset: null,\n presetSource: "off",\n effectivePreset: null,\n };\n }\n\n return {\n preset: null,\n presetSource: "global",\n effectivePreset: globalPreset,\n };\n}\n\nfunction isSqliteBusyError(error) {\n return error instanceof Error && /SQLITE_BUSY|database is locked/i.test(error.message);\n}\n\nfunction sleepSync(milliseconds) {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);\n}\n\nfunction withSqliteBusyRetry(operation, maxAttempts = 5, delayMs = 25) {\n for (let attempt = 1; ; attempt += 1) {\n try {\n return operation();\n } catch (error) {\n if (!isSqliteBusyError(error) || attempt >= maxAttempts) {\n throw error;\n }\n\n sleepSync(delayMs);\n }\n }\n}\n\nfunction truncateTelegramText(text, maxLength) {\n if (text.length <= maxLength) {\n return text;\n }\n\n return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n\nfunction buildTelegramNotificationText(sessionRef, sessionTitle, message, preset) {\n const headerParts = [];\n if (typeof sessionRef === "string" && sessionRef.trim().length > 0) {\n headerParts.push(`[${sessionRef.trim()}]`);\n }\n if (typeof sessionTitle === "string" && sessionTitle.trim().length > 0) {\n headerParts.push(sessionTitle.trim());\n }\n\n const header = headerParts.join(" - ");\n const segments = [header, String(message ?? "").trim()].filter(\n (segment) => typeof segment === "string" && segment.length > 0,\n );\n\n const replyCommandFooter =\n typeof sessionRef === "string" && sessionRef.trim().length > 0\n ? `Or send /reply ${sessionRef.trim()} your message.`\n : null;\n\n if (preset === "await-reply" || preset === "completion-checks") {\n segments.push("---------", telegramNotificationFooter);\n if (replyCommandFooter) {\n segments.push(replyCommandFooter);\n }\n } else if (preset === "infinite" || preset === "max-turns-1" || preset === "max-turns-2" || preset === "max-turns-3") {\n segments.push(\n "---------",\n "Reply to this message in Telegram to replace the prompt that will keep being sent to this Codex chat.",\n );\n if (replyCommandFooter) {\n segments.push(replyCommandFooter);\n }\n }\n\n return truncateTelegramText(segments.join("\\n\\n"), telegramMaxMessageLength);\n}\n\nfunction buildTelegramBotUrl(botToken) {\n return `https://api.telegram.org/bot${botToken}/sendMessage`;\n}\n\nasync function ensureDirectory(path) {\n await mkdir(path, { recursive: true });\n}\n\nfunction shouldEnableHookDebugLogging() {\n return isTruthyEnvValue(process.env[hookDebugLogEnvName]);\n}\n\nfunction sanitizeHookDebugLogValue(value, seen = new WeakSet()) {\n if (value == null || typeof value === "boolean" || typeof value === "number") {\n return value;\n }\n\n if (typeof value === "string") {\n return value;\n }\n\n if (Array.isArray(value)) {\n return value.map((item) => sanitizeHookDebugLogValue(item, seen));\n }\n\n if (typeof value !== "object") {\n return String(value);\n }\n\n if (seen.has(value)) {\n return "[circular]";\n }\n\n seen.add(value);\n\n return Object.fromEntries(\n Object.entries(value).map(([entryKey, entryValue]) => {\n if (\n hookDebugRedactedKeys.includes(entryKey) ||\n /(token|secret|password)$/i.test(entryKey)\n ) {\n return [entryKey, redactedDebugValue];\n }\n\n return [entryKey, sanitizeHookDebugLogValue(entryValue, seen)];\n }),\n );\n}\n\nasync function appendHookDebugLog(entry) {\n if (!shouldEnableHookDebugLogging()) {\n return;\n }\n\n await ensureDirectory(logsDirectoryPath);\n await appendFile(\n hookDebugLogPath,\n `${JSON.stringify(\n sanitizeHookDebugLogValue({\n timestamp: nowIsoString(),\n ...entry,\n }),\n )}\\n`,\n "utf8",\n );\n}\n\nfunction configureDatabase(db) {\n for (const statement of sqlitePragmas) {\n db.exec(statement);\n }\n}\n\nfunction shouldIgnoreMigrationStatementError(db, statement, error) {\n const message = error instanceof Error ? error.message : String(error);\n if (!message.toLowerCase().includes("duplicate column name:")) {\n return false;\n }\n\n const match = /^\\s*alter\\s+table\\s+(\\w+)\\s+add\\s+column\\s+(\\w+)/i.exec(statement);\n if (!match) {\n return false;\n }\n\n const [, tableName, columnName] = match;\n const rows = db.query(`pragma table_info(${tableName})`).all();\n return rows.some((row) => row.name === columnName);\n}\n\nfunction applyMigrations(db) {\n db.exec(`create table if not exists schema_migrations (\n id integer primary key,\n name text not null,\n applied_at text not null\n )`);\n\n const appliedRows = db.query("select id from schema_migrations order by id asc").all();\n const appliedIds = new Set(appliedRows.map((row) => row.id));\n const insertMigration = db.query(\n "insert into schema_migrations (id, name, applied_at) values (?, ?, ?)",\n );\n const applyMigration = db.transaction((migration) => {\n for (const statement of migration.statements) {\n try {\n db.exec(statement);\n } catch (error) {\n if (shouldIgnoreMigrationStatementError(db, statement, error)) {\n continue;\n }\n\n throw error;\n }\n }\n\n insertMigration.run(migration.id, migration.name, nowIsoString());\n });\n\n for (const migration of appMigrations) {\n if (appliedIds.has(migration.id)) {\n continue;\n }\n\n applyMigration(migration);\n }\n}\n\nfunction getSettings(db) {\n const row = db\n .query(\n "select default_prompt, scope, global_preset, global_notification_id, global_completion_check_id, global_completion_check_wait_for_reply, hooks_auto_registration from settings where id = 1",\n )\n .get();\n if (!row) {\n throw new Error("Loopndroll settings row is missing.");\n }\n\n return row;\n}\n\nfunction getValidGlobalNotificationId(db, candidate) {\n if (typeof candidate !== "string") {\n return null;\n }\n\n const notificationId = candidate.trim();\n if (notificationId.length === 0) {\n return null;\n }\n\n const existingNotification = db\n .query("select id from notifications where id = ?")\n .get(notificationId);\n\n return existingNotification ? notificationId : null;\n}\n\nfunction getValidGlobalCompletionCheckId(db, candidate) {\n if (typeof candidate !== "string") {\n return null;\n }\n\n const completionCheckId = candidate.trim();\n if (completionCheckId.length === 0) {\n return null;\n }\n\n const existingCompletionCheck = db\n .query("select id from completion_checks where id = ?")\n .get(completionCheckId);\n\n return existingCompletionCheck ? completionCheckId : null;\n}\n\nfunction parseCompletionCheckCommands(commandsJson) {\n try {\n const parsed = JSON.parse(commandsJson);\n return Array.isArray(parsed)\n ? parsed.map((command) => String(command).trim()).filter((command) => command.length > 0)\n : [];\n } catch {\n return [];\n }\n}\n\nfunction getActiveCompletionCheckForSession(db, sessionId) {\n const row = db\n .query(\n `select\n s.preset as session_preset,\n s.preset_overridden as preset_overridden,\n s.completion_check_id as session_completion_check_id,\n s.completion_check_wait_for_reply as session_completion_check_wait_for_reply,\n st.global_preset as global_preset,\n st.global_completion_check_id as global_completion_check_id,\n st.global_completion_check_wait_for_reply as global_completion_check_wait_for_reply\n from sessions s\n left join settings st on st.id = 1\n where s.session_id = ?\n limit 1`,\n )\n .get(sessionId);\n if (!row) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n const presetState = resolveSessionPresetState(\n row.session_preset,\n row.preset_overridden,\n row.global_preset,\n );\n const usesSessionConfig = presetState.presetSource === "session";\n const completionCheckId = getValidGlobalCompletionCheckId(\n db,\n usesSessionConfig ? row.session_completion_check_id : row.global_completion_check_id,\n );\n if (completionCheckId === null) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n const completionCheckRow = db\n .query(\n "select id, label, commands_json from completion_checks where id = ? limit 1",\n )\n .get(completionCheckId);\n if (!completionCheckRow) {\n return {\n completionCheck: null,\n waitForReplyAfterCompletion: false,\n };\n }\n\n return {\n completionCheck: {\n id: completionCheckRow.id,\n label: completionCheckRow.label,\n commands: parseCompletionCheckCommands(completionCheckRow.commands_json),\n },\n waitForReplyAfterCompletion: usesSessionConfig\n ? Boolean(row.session_completion_check_wait_for_reply)\n : Boolean(row.global_completion_check_wait_for_reply),\n };\n}\n\nfunction summarizeCompletionCheckOutput(output) {\n const normalizedOutput = String(output ?? "").trim();\n if (normalizedOutput.length === 0) {\n return null;\n }\n\n const lines = normalizedOutput.split(/\\r?\\n/).map((line) => line.trimEnd()).filter(Boolean);\n const tail = lines.slice(-8).join("\\n").trim();\n return tail.length > 0 ? tail : null;\n}\n\nfunction runCompletionCheckCommands(input, completionCheck) {\n const cwd = typeof input?.cwd === "string" && input.cwd.trim().length > 0 ? input.cwd : null;\n if (cwd === null) {\n return {\n status: "skipped",\n };\n }\n\n if (!completionCheck || !Array.isArray(completionCheck.commands) || completionCheck.commands.length === 0) {\n return {\n status: "skipped",\n };\n }\n\n for (const command of completionCheck.commands) {\n const result = spawnSync("/bin/sh", ["-lc", command], {\n cwd,\n encoding: "utf8",\n maxBuffer: 10 * 1024 * 1024,\n });\n const combinedOutput = [result.stdout, result.stderr].filter(Boolean).join("\\n").trim();\n\n if (result.error) {\n const outputSummary = summarizeCompletionCheckOutput(combinedOutput);\n return {\n status: "failed",\n reason: [\n "Completion check failed while running:",\n command,\n outputSummary ? "" : "The command exited before completion.",\n outputSummary ? `Recent output:\\n${outputSummary}` : null,\n "",\n "Fix issues.",\n ]\n .filter(Boolean)\n .join("\\n"),\n };\n }\n\n if (result.status !== 0) {\n const outputSummary = summarizeCompletionCheckOutput(combinedOutput);\n return {\n status: "failed",\n reason: [\n "Completion check failed while running:",\n command,\n `Exit code: ${result.status}`,\n outputSummary ? `Recent output:\\n${outputSummary}` : null,\n "",\n "Fix issues.",\n ]\n .filter(Boolean)\n .join("\\n"),\n };\n }\n }\n\n return {\n status: "passed",\n };\n}\n\nfunction applyGlobalNotificationToSession(db, sessionId, notificationId) {\n if (notificationId === null) {\n return;\n }\n\n withSqliteBusyRetry(() =>\n db\n .query(\n `insert into session_notifications (session_id, notification_id)\n values (?, ?)\n on conflict(session_id, notification_id) do nothing`,\n )\n .run(sessionId, notificationId),\n );\n}\n\nfunction allocateNextSessionRef(db) {\n const allocate = db.transaction(() => {\n const row = db.query("select last_value from session_ref_sequence where id = 1").get();\n const nextValue = (typeof row?.last_value === "number" ? row.last_value : 0) + 1;\n\n db.query(\n `insert into session_ref_sequence (id, last_value)\n values (1, ?)\n on conflict(id) do update set last_value = excluded.last_value`,\n ).run(nextValue);\n\n return `C${nextValue}`;\n });\n\n return withSqliteBusyRetry(() => allocate());\n}\n\nfunction buildNewSession(db, sessionId) {\n const timestamp = nowIsoString();\n return {\n sessionId,\n sessionRef: allocateNextSessionRef(db),\n source: "startup",\n cwd: null,\n archived: false,\n firstSeenAt: timestamp,\n lastSeenAt: timestamp,\n activeSince: null,\n stopCount: 0,\n preset: null,\n presetOverridden: false,\n title: null,\n transcriptPath: null,\n lastAssistantMessage: null,\n };\n}\n\nfunction getSession(db, sessionId) {\n const row = db\n .query(\n `select\n session_id,\n session_ref,\n source,\n cwd,\n archived,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n preset_overridden,\n title,\n transcript_path,\n last_assistant_message\n from sessions\n where session_id = ?`,\n )\n .get(sessionId);\n\n if (!row) {\n return null;\n }\n\n return {\n sessionId: row.session_id,\n sessionRef: row.session_ref,\n source: row.source,\n cwd: row.cwd,\n archived: Boolean(row.archived),\n firstSeenAt: row.first_seen_at,\n lastSeenAt: row.last_seen_at,\n activeSince: row.active_since,\n stopCount: row.stop_count,\n preset: row.preset,\n presetOverridden: Boolean(row.preset_overridden),\n title: row.title,\n transcriptPath: row.transcript_path,\n lastAssistantMessage: row.last_assistant_message,\n };\n}\n\nfunction writeSession(db, session, existing) {\n if (existing) {\n withSqliteBusyRetry(() =>\n db\n .query(\n `update sessions\n set session_ref = ?,\n source = ?,\n cwd = ?,\n archived = ?,\n first_seen_at = ?,\n last_seen_at = ?,\n'; diff --git a/src/bun/managed-hook-script/chunk-2.ts b/src/bun/managed-hook-script/chunk-2.ts index df5ef22..5feff6a 100644 --- a/src/bun/managed-hook-script/chunk-2.ts +++ b/src/bun/managed-hook-script/chunk-2.ts @@ -1 +1,2 @@ -export const MANAGED_HOOK_SCRIPT_CHUNK_2 = " active_since = ?,\n stop_count = ?,\n preset = ?,\n preset_overridden = ?,\n title = ?,\n transcript_path = ?,\n last_assistant_message = ?\n where session_id = ?`,\n )\n .run(\n session.sessionRef,\n session.source,\n session.cwd,\n session.archived ? 1 : 0,\n session.firstSeenAt,\n session.lastSeenAt,\n session.activeSince,\n session.stopCount,\n session.preset,\n session.presetOverridden ? 1 : 0,\n session.title,\n session.transcriptPath,\n session.lastAssistantMessage,\n session.sessionId,\n ),\n );\n return;\n }\n\n withSqliteBusyRetry(() =>\n db\n .query(\n `insert into sessions (\n session_id,\n session_ref,\n source,\n cwd,\n archived,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n preset_overridden,\n title,\n transcript_path,\n last_assistant_message\n ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n )\n .run(\n session.sessionId,\n session.sessionRef,\n session.source,\n session.cwd,\n session.archived ? 1 : 0,\n session.firstSeenAt,\n session.lastSeenAt,\n session.activeSince,\n session.stopCount,\n session.preset,\n session.presetOverridden ? 1 : 0,\n session.title,\n session.transcriptPath,\n session.lastAssistantMessage,\n ),\n );\n}\n\nfunction deriveSessionTitle(prompt) {\n const normalizedPrompt = String(prompt ?? \"\").replace(/\\s+/g, \" \").trim();\n if (normalizedPrompt.length === 0) {\n return null;\n }\n\n return normalizedPrompt.slice(0, 80);\n}\n\nfunction upsertSession(db, input, source) {\n const existing = getSession(db, input.session_id);\n const next = existing ? { ...existing } : buildNewSession(db, input.session_id);\n\n next.source = source;\n if (typeof input.cwd === \"string\" && input.cwd.length > 0) {\n next.cwd = input.cwd;\n }\n next.lastSeenAt = nowIsoString();\n next.firstSeenAt = existing?.firstSeenAt ?? next.firstSeenAt;\n if (typeof input.transcript_path === \"string\" && input.transcript_path.length > 0) {\n next.transcriptPath = input.transcript_path;\n }\n if (typeof input.last_assistant_message === \"string\") {\n next.lastAssistantMessage = input.last_assistant_message;\n }\n if (typeof input.prompt === \"string\" && !next.title) {\n next.title = deriveSessionTitle(input.prompt);\n }\n\n const effectivePreset = resolveSessionPresetState(\n next.preset,\n next.presetOverridden,\n getSettings(db).global_preset,\n ).effectivePreset;\n if (effectivePreset !== null && next.activeSince === null) {\n next.activeSince = nowIsoString();\n } else if (effectivePreset === null && next.activeSince !== null) {\n next.activeSince = null;\n }\n\n writeSession(db, next, existing);\n if (!existing) {\n applyGlobalNotificationToSession(\n db,\n next.sessionId,\n getValidGlobalNotificationId(db, getSettings(db).global_notification_id),\n );\n }\n return next;\n}\n\nfunction syncInheritedSessionActiveSinceForGlobalPresetChange(\n db: Database,\n preset: LoopPreset | null,\n timestamp = nowIsoString(),\n) {\n if (preset === null) {\n db.query(\n `update sessions\n set active_since = null\n where archived = 0\n and preset_overridden = 0`,\n ).run();\n return;\n }\n\n db.query(\n `update sessions\n set active_since = coalesce(active_since, ?)\n where archived = 0\n and preset_overridden = 0`,\n ).run(timestamp);\n}\n\nfunction isPromptOnlyArtifact(session) {\n if (session.transcriptPath !== null) {\n return false;\n }\n\n const titleLooksInternal = session.title?.startsWith(\"You are a helpful assistant.\") ?? false;\n const assistantPayloadLooksInternal = session.lastAssistantMessage?.startsWith(\"{\\\"title\\\":\") ?? false;\n\n return titleLooksInternal || assistantPayloadLooksInternal;\n}\n\nfunction parseGeneratedTitlePayload(message) {\n if (typeof message !== \"string\" || message.trim().length === 0) {\n return null;\n }\n\n try {\n const parsed = JSON.parse(message);\n return typeof parsed?.title === \"string\" && parsed.title.trim().length > 0\n ? parsed.title.trim()\n : null;\n } catch {\n return null;\n }\n}\n\nfunction findGeneratedTitleTargetSession(db, input) {\n if (typeof input.cwd !== \"string\" || input.cwd.length === 0) {\n return null;\n }\n\n const cutoffIso = new Date(Date.now() - generatedTitleMatchWindowMs).toISOString();\n const rows = db\n .query(\n `select\n session_id,\n session_ref,\n source,\n cwd,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n title,\n transcript_path,\n last_assistant_message\n from sessions\n where session_id != ?\n and cwd = ?\n and transcript_path is not null\n and last_seen_at >= ?\n order by last_seen_at desc`,\n )\n .all(input.session_id, input.cwd, cutoffIso)\n .map((row) => ({\n sessionId: row.session_id,\n sessionRef: row.session_ref,\n source: row.source,\n cwd: row.cwd,\n archived: Boolean(row.archived),\n firstSeenAt: row.first_seen_at,\n lastSeenAt: row.last_seen_at,\n activeSince: row.active_since,\n stopCount: row.stop_count,\n preset: row.preset,\n title: row.title,\n transcriptPath: row.transcript_path,\n lastAssistantMessage: row.last_assistant_message,\n }));\n\n return rows.find((session) => !isPromptOnlyArtifact(session)) ?? null;\n}\n\nfunction updateSessionTitle(db, sessionId, title) {\n db.query(\"update sessions set title = ? where session_id = ?\").run(title, sessionId);\n}\n\nfunction getRemainingTurns(db, sessionId) {\n const row = db\n .query(\"select remaining_turns from session_runtime where session_id = ?\")\n .get(sessionId);\n return typeof row?.remaining_turns === \"number\" ? row.remaining_turns : null;\n}\n\nfunction setRemainingTurns(db, sessionId, remainingTurns) {\n db.query(\n `insert into session_runtime (session_id, remaining_turns)\n values (?, ?)\n on conflict(session_id) do update set remaining_turns = excluded.remaining_turns`,\n ).run(sessionId, remainingTurns);\n}\n\nfunction clearRemainingTurns(db, sessionId) {\n db.query(\"delete from session_runtime where session_id = ?\").run(sessionId);\n}\n\nfunction clearSessionAwaitingReplies(db, sessionId) {\n db.query(\"delete from session_awaiting_replies where session_id = ?\").run(sessionId);\n}\n\nfunction replaceSessionAwaitingReplies(db, sessionId, turnId, telegramTargets) {\n clearSessionAwaitingReplies(db, sessionId);\n\n if (!Array.isArray(telegramTargets) || telegramTargets.length === 0) {\n return 0;\n }\n\n const insertAwaitingReply = db.query(\n `insert into session_awaiting_replies (\n session_id,\n bot_token,\n chat_id,\n turn_id,\n started_at\n ) values (?, ?, ?, ?, ?)`,\n );\n const startedAt = nowIsoString();\n let insertedCount = 0;\n const seenTargets = new Set();\n\n for (const target of telegramTargets) {\n const botToken = typeof target?.botToken === \"string\" ? target.botToken.trim() : \"\";\n const chatId = typeof target?.chatId === \"string\" ? target.chatId.trim() : \"\";\n if (botToken.length === 0 || chatId.length === 0) {\n continue;\n }\n\n const dedupeKey = `${botToken}::${chatId}`;\n if (seenTargets.has(dedupeKey)) {\n continue;\n }\n\n seenTargets.add(dedupeKey);\n insertAwaitingReply.run(sessionId, botToken, chatId, turnId ?? null, startedAt);\n insertedCount += 1;\n }\n\n return insertedCount;\n}\n\nfunction getSessionRemotePrompt(db, sessionId, deliveryMode) {\n const row = db\n .query(\n \"select prompt_text from session_remote_prompts where session_id = ? and delivery_mode = ?\",\n )\n .get(sessionId, deliveryMode);\n const promptText =\n typeof row?.prompt_text === \"string\" && row.prompt_text.trim().length > 0\n ? row.prompt_text.trim()\n : null;\n\n return promptText;\n}\n\nfunction consumeSessionRemotePrompt(db, sessionId) {\n const promptText = getSessionRemotePrompt(db, sessionId, \"once\");\n\n if (promptText === null) {\n return null;\n }\n\n db.query(\n \"delete from session_remote_prompts where session_id = ? and delivery_mode = 'once'\",\n ).run(sessionId);\n return promptText;\n}\n\nfunction readPersistentSessionRemotePrompt(db, sessionId) {\n return getSessionRemotePrompt(db, sessionId, \"persistent\");\n}\n\nfunction renderPrompt(template, remainingTurns) {\n return template\n .replaceAll(\"{{remaining_turns}}\", remainingTurns === null ? \"\" : String(remainingTurns))\n .trim();\n}\n\nfunction getMaxTurns(preset) {\n if (preset === \"max-turns-1\") return 1;\n if (preset === \"max-turns-2\") return 2;\n if (preset === \"max-turns-3\") return 3;\n return null;\n}\n\nfunction getEffectivePreset(db, sessionId) {\n const row = db\n .query(\n `select\n s.preset as session_preset,\n s.preset_overridden as preset_overridden,\n s.archived as session_archived,\n st.global_preset as global_preset\n from sessions s\n left join settings st on st.id = 1\n where s.session_id = ?\n limit 1`,\n )\n .get(sessionId);\n\n if (!row) {\n return null;\n }\n\n if (Boolean(row.session_archived)) {\n return null;\n }\n\n return resolveSessionPresetState(\n row.session_preset,\n row.preset_overridden,\n row.global_preset,\n ).effectivePreset;\n}\n\nfunction toHookStopOutput(stopDecision) {\n if (!stopDecision || typeof stopDecision !== \"object\") {\n return null;\n }\n\n if (stopDecision.continue === false) {\n return {\n continue: false,\n stopReason:\n typeof stopDecision.stopReason === \"string\" ? stopDecision.stopReason : undefined,\n };\n }\n\n if (stopDecision.decision === \"block\" && typeof stopDecision.reason === \"string\") {\n return {\n decision: \"block\",\n reason: stopDecision.reason,\n };\n }\n\n return null;\n}\n\nfunction shouldWaitForReplyAfterCompletion(db, sessionId) {\n const activeGlobalCompletionCheck = getActiveCompletionCheckForSession(db, sessionId);\n return (\n activeGlobalCompletionCheck.completionCheck !== null &&\n activeGlobalCompletionCheck.waitForReplyAfterCompletion\n );\n}\n\nasync function waitForAwaitReplyResolution(db, sessionId, waitMode = \"await-reply\") {\n while (true) {\n const remotePrompt = consumeSessionRemotePrompt(db, sessionId);\n if (remotePrompt) {\n return {\n type: \"prompt\",\n prompt: remotePrompt,\n };\n }\n\n const effectivePreset = getEffectivePreset(db, sessionId);\n const shouldKeepWaiting =\n waitMode === \"completion-checks\"\n ? effectivePreset === \"completion-checks\" &&\n shouldWaitForReplyAfterCompletion(db, sessionId)\n : effectivePreset === \"await-reply\";\n if (!shouldKeepWaiting) {\n return {\n type: \"preset-change\",\n preset: effectivePreset,\n };\n }\n\n await Bun.sleep(awaitReplyPollIntervalMs);\n }\n}\n\nasync function applyStopDecision(db, settingsRow, sessionId, preset, input, telegramTargets) {\n if (!preset) {\n clearRemainingTurns(db, sessionId);\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n if (preset === \"infinite\") {\n clearSessionAwaitingReplies(db, sessionId);\n const remotePrompt =\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSessionRemotePrompt(db, sessionId);\n return {\n decision: \"block\",\n reason: remotePrompt ?? renderPrompt(settingsRow.default_prompt, null),\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: remotePrompt ? \"telegram\" : \"default\",\n };\n }\n\n if (preset === \"await-reply\") {\n clearRemainingTurns(db, sessionId);\n\n const queuedPrompt = consumeSessionRemotePrompt(db, sessionId);\n if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: \"block\",\n reason: queuedPrompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: \"telegram\",\n };\n }\n\n const awaitingReplyCount = replaceSessionAwaitingReplies(\n db,\n sessionId,\n typeof input?.turn_id === \"string\" ? input.turn_id : null,\n telegramTargets,\n );\n if (awaitingReplyCount <= 0) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const resolution = await waitForAwaitReplyResolution(db, sessionId);\n clearSessionAwaitingReplies(db, sessionId);\n if (resolution?.type === \"prompt\") {\n return {\n decision: \"block\",\n reason: resolution.prompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: \"telegram\",\n };\n }\n\n if (resolution?.type === \"preset-change\") {\n const nextSettingsRow = getSettings(db);\n return await applyStopDecision(\n db,\n nextSettingsRow,\n sessionId,\n resolution.preset,\n input,\n [],\n );\n }\n\n return null;\n }\n\n if (preset === \"completion-checks\") {\n clearRemainingTurns(db, sessionId);\n\n const activeGlobalCompletionCheck = getActiveCompletionCheckForSession(db, sessionId);\n const runResult = runCompletionCheckCommands(input, activeGlobalCompletionCheck.completionCheck);\n if (runResult.status === \"failed\") {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: \"block\",\n reason: runResult.reason,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: \"default\",\n };\n }\n\n if (!activeGlobalCompletionCheck.waitForReplyAfterCompletion) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const queuedPrompt = consumeSessionRemotePrompt(db, sessionId);\n if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: \"block\",\n reason: queuedPrompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: \"telegram\",\n };\n }\n\n const awaitingReplyCount = replaceSessionAwaitingReplies(\n db,\n sessionId,\n typeof input?.turn_id === \"string\" ? input.turn_id : null,\n telegramTargets,\n );\n if (awaitingReplyCount <= 0) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const resolution = await waitForAwaitReplyResolution(db, sessionId, \"completion-checks\");\n clearSessionAwaitingReplies(db, sessionId);\n if (resolution?.type === \"prompt\") {\n return {\n decision: \"block\",\n reason: resolution.prompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: \"telegram\",\n };\n }\n\n if (resolution?.type === \"preset-change\") {\n const nextSettingsRow = getSettings(db);\n return await applyStopDecision(\n db,\n nextSettingsRow,\n sessionId,\n"; +export const MANAGED_HOOK_SCRIPT_CHUNK_2 = + ' active_since = ?,\n stop_count = ?,\n preset = ?,\n preset_overridden = ?,\n title = ?,\n transcript_path = ?,\n last_assistant_message = ?\n where session_id = ?`,\n )\n .run(\n session.sessionRef,\n session.source,\n session.cwd,\n session.archived ? 1 : 0,\n session.firstSeenAt,\n session.lastSeenAt,\n session.activeSince,\n session.stopCount,\n session.preset,\n session.presetOverridden ? 1 : 0,\n session.title,\n session.transcriptPath,\n session.lastAssistantMessage,\n session.sessionId,\n ),\n );\n return;\n }\n\n withSqliteBusyRetry(() =>\n db\n .query(\n `insert into sessions (\n session_id,\n session_ref,\n source,\n cwd,\n archived,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n preset_overridden,\n title,\n transcript_path,\n last_assistant_message\n ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n )\n .run(\n session.sessionId,\n session.sessionRef,\n session.source,\n session.cwd,\n session.archived ? 1 : 0,\n session.firstSeenAt,\n session.lastSeenAt,\n session.activeSince,\n session.stopCount,\n session.preset,\n session.presetOverridden ? 1 : 0,\n session.title,\n session.transcriptPath,\n session.lastAssistantMessage,\n ),\n );\n}\n\nfunction deriveSessionTitle(prompt) {\n const normalizedPrompt = String(prompt ?? "").replace(/\\s+/g, " ").trim();\n if (normalizedPrompt.length === 0) {\n return null;\n }\n\n return normalizedPrompt.slice(0, 80);\n}\n\nfunction upsertSession(db, input, source) {\n const existing = getSession(db, input.session_id);\n const next = existing ? { ...existing } : buildNewSession(db, input.session_id);\n\n next.source = source;\n if (typeof input.cwd === "string" && input.cwd.length > 0) {\n next.cwd = input.cwd;\n }\n next.lastSeenAt = nowIsoString();\n next.firstSeenAt = existing?.firstSeenAt ?? next.firstSeenAt;\n if (typeof input.transcript_path === "string" && input.transcript_path.length > 0) {\n next.transcriptPath = input.transcript_path;\n }\n if (typeof input.last_assistant_message === "string") {\n next.lastAssistantMessage = input.last_assistant_message;\n }\n if (typeof input.prompt === "string" && !next.title) {\n next.title = deriveSessionTitle(input.prompt);\n }\n\n const effectivePreset = resolveSessionPresetState(\n next.preset,\n next.presetOverridden,\n getSettings(db).global_preset,\n ).effectivePreset;\n if (effectivePreset !== null && next.activeSince === null) {\n next.activeSince = nowIsoString();\n } else if (effectivePreset === null && next.activeSince !== null) {\n next.activeSince = null;\n }\n\n writeSession(db, next, existing);\n if (!existing) {\n applyGlobalNotificationToSession(\n db,\n next.sessionId,\n getValidGlobalNotificationId(db, getSettings(db).global_notification_id),\n );\n }\n return next;\n}\n\nfunction syncInheritedSessionActiveSinceForGlobalPresetChange(\n db: Database,\n preset: LoopPreset | null,\n timestamp = nowIsoString(),\n) {\n if (preset === null) {\n db.query(\n `update sessions\n set active_since = null\n where archived = 0\n and preset_overridden = 0`,\n ).run();\n return;\n }\n\n db.query(\n `update sessions\n set active_since = coalesce(active_since, ?)\n where archived = 0\n and preset_overridden = 0`,\n ).run(timestamp);\n}\n\nfunction isPromptOnlyArtifact(session) {\n if (session.transcriptPath !== null) {\n return false;\n }\n\n const titleLooksInternal = session.title?.startsWith("You are a helpful assistant.") ?? false;\n const assistantPayloadLooksInternal = session.lastAssistantMessage?.startsWith("{\\"title\\":") ?? false;\n\n return titleLooksInternal || assistantPayloadLooksInternal;\n}\n\nfunction parseGeneratedTitlePayload(message) {\n if (typeof message !== "string" || message.trim().length === 0) {\n return null;\n }\n\n try {\n const parsed = JSON.parse(message);\n return typeof parsed?.title === "string" && parsed.title.trim().length > 0\n ? parsed.title.trim()\n : null;\n } catch {\n return null;\n }\n}\n\nfunction findGeneratedTitleTargetSession(db, input) {\n if (typeof input.cwd !== "string" || input.cwd.length === 0) {\n return null;\n }\n\n const cutoffIso = new Date(Date.now() - generatedTitleMatchWindowMs).toISOString();\n const rows = db\n .query(\n `select\n session_id,\n session_ref,\n source,\n cwd,\n first_seen_at,\n last_seen_at,\n active_since,\n stop_count,\n preset,\n title,\n transcript_path,\n last_assistant_message\n from sessions\n where session_id != ?\n and cwd = ?\n and transcript_path is not null\n and last_seen_at >= ?\n order by last_seen_at desc`,\n )\n .all(input.session_id, input.cwd, cutoffIso)\n .map((row) => ({\n sessionId: row.session_id,\n sessionRef: row.session_ref,\n source: row.source,\n cwd: row.cwd,\n archived: Boolean(row.archived),\n firstSeenAt: row.first_seen_at,\n lastSeenAt: row.last_seen_at,\n activeSince: row.active_since,\n stopCount: row.stop_count,\n preset: row.preset,\n title: row.title,\n transcriptPath: row.transcript_path,\n lastAssistantMessage: row.last_assistant_message,\n }));\n\n return rows.find((session) => !isPromptOnlyArtifact(session)) ?? null;\n}\n\nfunction updateSessionTitle(db, sessionId, title) {\n db.query("update sessions set title = ? where session_id = ?").run(title, sessionId);\n}\n\nfunction getRemainingTurns(db, sessionId) {\n const row = db\n .query("select remaining_turns from session_runtime where session_id = ?")\n .get(sessionId);\n return typeof row?.remaining_turns === "number" ? row.remaining_turns : null;\n}\n\nfunction setRemainingTurns(db, sessionId, remainingTurns) {\n db.query(\n `insert into session_runtime (session_id, remaining_turns)\n values (?, ?)\n on conflict(session_id) do update set remaining_turns = excluded.remaining_turns`,\n ).run(sessionId, remainingTurns);\n}\n\nfunction clearRemainingTurns(db, sessionId) {\n db.query("delete from session_runtime where session_id = ?").run(sessionId);\n}\n\nfunction clearSessionAwaitingReplies(db, sessionId) {\n db.query("delete from session_awaiting_replies where session_id = ?").run(sessionId);\n}\n\nfunction replaceSessionAwaitingReplies(db, sessionId, turnId, telegramTargets) {\n clearSessionAwaitingReplies(db, sessionId);\n\n if (!Array.isArray(telegramTargets) || telegramTargets.length === 0) {\n return 0;\n }\n\n const insertAwaitingReply = db.query(\n `insert into session_awaiting_replies (\n session_id,\n bot_token,\n chat_id,\n turn_id,\n started_at\n ) values (?, ?, ?, ?, ?)`,\n );\n const startedAt = nowIsoString();\n let insertedCount = 0;\n const seenTargets = new Set();\n\n for (const target of telegramTargets) {\n const botToken = typeof target?.botToken === "string" ? target.botToken.trim() : "";\n const chatId = typeof target?.chatId === "string" ? target.chatId.trim() : "";\n if (botToken.length === 0 || chatId.length === 0) {\n continue;\n }\n\n const dedupeKey = `${botToken}::${chatId}`;\n if (seenTargets.has(dedupeKey)) {\n continue;\n }\n\n seenTargets.add(dedupeKey);\n insertAwaitingReply.run(sessionId, botToken, chatId, turnId ?? null, startedAt);\n insertedCount += 1;\n }\n\n return insertedCount;\n}\n\nfunction getSessionRemotePrompt(db, sessionId, deliveryMode) {\n const row = db\n .query(\n "select prompt_text from session_remote_prompts where session_id = ? and delivery_mode = ?",\n )\n .get(sessionId, deliveryMode);\n const promptText =\n typeof row?.prompt_text === "string" && row.prompt_text.trim().length > 0\n ? row.prompt_text.trim()\n : null;\n\n return promptText;\n}\n\nfunction consumeSessionRemotePrompt(db, sessionId) {\n const promptText = getSessionRemotePrompt(db, sessionId, "once");\n\n if (promptText === null) {\n return null;\n }\n\n db.query(\n "delete from session_remote_prompts where session_id = ? and delivery_mode = \'once\'",\n ).run(sessionId);\n return promptText;\n}\n\nfunction readPersistentSessionRemotePrompt(db, sessionId) {\n return getSessionRemotePrompt(db, sessionId, "persistent");\n}\n\nfunction renderPrompt(template, remainingTurns) {\n return template\n .replaceAll("{{remaining_turns}}", remainingTurns === null ? "" : String(remainingTurns))\n .trim();\n}\n\nfunction getMaxTurns(preset) {\n if (preset === "max-turns-1") return 1;\n if (preset === "max-turns-2") return 2;\n if (preset === "max-turns-3") return 3;\n return null;\n}\n\nfunction getEffectivePreset(db, sessionId) {\n const row = db\n .query(\n `select\n s.preset as session_preset,\n s.preset_overridden as preset_overridden,\n s.archived as session_archived,\n st.global_preset as global_preset\n from sessions s\n left join settings st on st.id = 1\n where s.session_id = ?\n limit 1`,\n )\n .get(sessionId);\n\n if (!row) {\n return null;\n }\n\n if (Boolean(row.session_archived)) {\n return null;\n }\n\n return resolveSessionPresetState(\n row.session_preset,\n row.preset_overridden,\n row.global_preset,\n ).effectivePreset;\n}\n\nfunction toHookStopOutput(stopDecision) {\n if (!stopDecision || typeof stopDecision !== "object") {\n return null;\n }\n\n if (stopDecision.continue === false) {\n return {\n continue: false,\n stopReason:\n typeof stopDecision.stopReason === "string" ? stopDecision.stopReason : undefined,\n };\n }\n\n if (stopDecision.decision === "block" && typeof stopDecision.reason === "string") {\n return {\n decision: "block",\n reason: stopDecision.reason,\n };\n }\n\n return null;\n}\n\nfunction shouldWaitForReplyAfterCompletion(db, sessionId) {\n const activeGlobalCompletionCheck = getActiveCompletionCheckForSession(db, sessionId);\n return (\n activeGlobalCompletionCheck.completionCheck !== null &&\n activeGlobalCompletionCheck.waitForReplyAfterCompletion\n );\n}\n\nasync function waitForAwaitReplyResolution(db, sessionId, waitMode = "await-reply") {\n while (true) {\n const remotePrompt = consumeSessionRemotePrompt(db, sessionId);\n if (remotePrompt) {\n return {\n type: "prompt",\n prompt: remotePrompt,\n };\n }\n\n const effectivePreset = getEffectivePreset(db, sessionId);\n const shouldKeepWaiting =\n waitMode === "completion-checks"\n ? effectivePreset === "completion-checks" &&\n shouldWaitForReplyAfterCompletion(db, sessionId)\n : effectivePreset === "await-reply";\n if (!shouldKeepWaiting) {\n return {\n type: "preset-change",\n preset: effectivePreset,\n };\n }\n\n await Bun.sleep(awaitReplyPollIntervalMs);\n }\n}\n\nasync function applyStopDecision(db, settingsRow, sessionId, preset, input, telegramTargets) {\n if (!preset) {\n clearRemainingTurns(db, sessionId);\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n if (preset === "infinite") {\n clearSessionAwaitingReplies(db, sessionId);\n const remotePrompt =\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSessionRemotePrompt(db, sessionId);\n return {\n decision: "block",\n reason: remotePrompt ?? renderPrompt(settingsRow.default_prompt, null),\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: remotePrompt ? "telegram" : "default",\n };\n }\n\n if (preset === "await-reply") {\n clearRemainingTurns(db, sessionId);\n\n const queuedPrompt = consumeSessionRemotePrompt(db, sessionId);\n if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: "block",\n reason: queuedPrompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: "telegram",\n };\n }\n\n const awaitingReplyCount = replaceSessionAwaitingReplies(\n db,\n sessionId,\n typeof input?.turn_id === "string" ? input.turn_id : null,\n telegramTargets,\n );\n if (awaitingReplyCount <= 0) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const resolution = await waitForAwaitReplyResolution(db, sessionId);\n clearSessionAwaitingReplies(db, sessionId);\n if (resolution?.type === "prompt") {\n return {\n decision: "block",\n reason: resolution.prompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: "telegram",\n };\n }\n\n if (resolution?.type === "preset-change") {\n const nextSettingsRow = getSettings(db);\n return await applyStopDecision(\n db,\n nextSettingsRow,\n sessionId,\n resolution.preset,\n input,\n [],\n );\n }\n\n return null;\n }\n\n if (preset === "completion-checks") {\n clearRemainingTurns(db, sessionId);\n\n const activeGlobalCompletionCheck = getActiveCompletionCheckForSession(db, sessionId);\n const runResult = runCompletionCheckCommands(input, activeGlobalCompletionCheck.completionCheck);\n if (runResult.status === "failed") {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: "block",\n reason: runResult.reason,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: "default",\n };\n }\n\n if (!activeGlobalCompletionCheck.waitForReplyAfterCompletion) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const queuedPrompt = consumeSessionRemotePrompt(db, sessionId);\n if (queuedPrompt) {\n clearSessionAwaitingReplies(db, sessionId);\n return {\n decision: "block",\n reason: queuedPrompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: "telegram",\n };\n }\n\n const awaitingReplyCount = replaceSessionAwaitingReplies(\n db,\n sessionId,\n typeof input?.turn_id === "string" ? input.turn_id : null,\n telegramTargets,\n );\n if (awaitingReplyCount <= 0) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const resolution = await waitForAwaitReplyResolution(db, sessionId, "completion-checks");\n clearSessionAwaitingReplies(db, sessionId);\n if (resolution?.type === "prompt") {\n return {\n decision: "block",\n reason: resolution.prompt,\n remainingTurnsBefore: null,\n remainingTurnsAfter: null,\n promptSource: "telegram",\n };\n }\n\n if (resolution?.type === "preset-change") {\n const nextSettingsRow = getSettings(db);\n return await applyStopDecision(\n db,\n nextSettingsRow,\n sessionId,\n'; diff --git a/src/bun/managed-hook-script/chunk-3.ts b/src/bun/managed-hook-script/chunk-3.ts index 898f9a8..af9b2bd 100644 --- a/src/bun/managed-hook-script/chunk-3.ts +++ b/src/bun/managed-hook-script/chunk-3.ts @@ -1 +1,113 @@ -export const MANAGED_HOOK_SCRIPT_CHUNK_3 = " resolution.preset,\n input,\n [],\n );\n }\n\n return null;\n }\n\n const maxTurns = getMaxTurns(preset);\n if (maxTurns === null) {\n clearRemainingTurns(db, sessionId);\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n const remainingTurns = getRemainingTurns(db, sessionId) ?? maxTurns;\n if (remainingTurns <= 0) {\n clearSessionAwaitingReplies(db, sessionId);\n return null;\n }\n\n setRemainingTurns(db, sessionId, remainingTurns - 1);\n clearSessionAwaitingReplies(db, sessionId);\n const remotePrompt =\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSessionRemotePrompt(db, sessionId);\n return {\n decision: \"block\",\n reason: remotePrompt ?? renderPrompt(settingsRow.default_prompt, remainingTurns - 1),\n remainingTurnsBefore: remainingTurns,\n remainingTurnsAfter: remainingTurns - 1,\n promptSource: remotePrompt ? \"telegram\" : \"default\",\n };\n}\n\nasync function sendStopNotifications(db, input) {\n if (input.hook_event_name !== \"Stop\") {\n return [];\n }\n\n const message =\n typeof input.last_assistant_message === \"string\" ? input.last_assistant_message.trim() : \"\";\n if (message.length === 0) {\n return [];\n }\n\n const selectedNotifications = db\n .query(\n `select\n n.id,\n n.label,\n n.channel,\n n.webhook_url,\n n.chat_id,\n n.bot_token,\n n.bot_url,\n n.created_at\n from notifications n\n inner join session_notifications sn on sn.notification_id = n.id\n where sn.session_id = ?\n order by n.created_at asc, n.id asc`,\n )\n .all(input.session_id);\n if (selectedNotifications.length === 0) {\n return [];\n }\n\n const sessionRow = db\n .query(\"select session_ref, title, archived from sessions where session_id = ?\")\n .get(input.session_id);\n if (Boolean(sessionRow?.archived)) {\n return [];\n }\n const effectivePreset = getEffectivePreset(db, input.session_id);\n const telegramText = buildTelegramNotificationText(\n sessionRow?.session_ref ?? null,\n sessionRow?.title ?? null,\n message,\n effectivePreset,\n );\n\n const deliveredTelegramTargets = [];\n const results = await Promise.allSettled(\n selectedNotifications.map(async (notification) => {\n if (notification.channel === \"slack\") {\n const response = await fetch(notification.webhook_url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({ text: message }),\n });\n if (!response.ok) {\n throw new Error(`Slack notification failed with status ${response.status}`);\n }\n return;\n }\n\n const telegramEndpoint =\n (typeof notification.bot_token === \"string\" && notification.bot_token.length > 0\n ? buildTelegramBotUrl(notification.bot_token)\n : notification.bot_url) ?? null;\n if (!telegramEndpoint) {\n throw new Error(\"Telegram notification is missing a bot token.\");\n }\n\n const response = await fetch(telegramEndpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded;charset=UTF-8\",\n },\n body: new URLSearchParams({\n chat_id: notification.chat_id,\n text: telegramText,\n }).toString(),\n });\n if (!response.ok) {\n throw new Error(`Telegram notification failed with status ${response.status}`);\n }\n\n const payload = await response.json();\n if (!payload?.ok || typeof payload?.result?.message_id !== \"number\") {\n throw new Error(payload?.description || \"Telegram notification did not return a message id.\");\n }\n\n db.query(\n `insert into telegram_delivery_receipts (\n id,\n notification_id,\n session_id,\n bot_token,\n chat_id,\n telegram_message_id,\n created_at\n ) values (?, ?, ?, ?, ?, ?, ?)`,\n ).run(\n crypto.randomUUID(),\n notification.id,\n input.session_id,\n typeof notification.bot_token === \"string\" ? notification.bot_token : \"\",\n notification.chat_id,\n payload.result.message_id,\n nowIsoString(),\n );\n\n deliveredTelegramTargets.push({\n botToken: typeof notification.bot_token === \"string\" ? notification.bot_token : \"\",\n chatId: notification.chat_id,\n });\n }),\n );\n\n const failures = results.flatMap((result, index) =>\n result.status === \"rejected\"\n ? [\n {\n notificationId: selectedNotifications[index]?.id ?? null,\n channel: selectedNotifications[index]?.channel ?? null,\n error: result.reason instanceof Error ? result.reason.message : String(result.reason),\n },\n ]\n : [],\n );\n\n await appendHookDebugLog({\n type: \"notification\",\n hookEventName: \"Stop\",\n sessionId: input.session_id,\n deliveredCount: results.length - failures.length,\n failedCount: failures.length,\n failures,\n });\n\n return deliveredTelegramTargets;\n}\n\nfunction getTelegramChatDisplayName(chat: {\n title?: string | null;\n first_name?: string | null;\n last_name?: string | null;\n username?: string | null;\n}) {\n const nameParts = [chat.first_name, chat.last_name].filter(\n (part): part is string => typeof part === \"string\" && part.trim().length > 0,\n );\n\n if (nameParts.length > 0) {\n return nameParts.join(\" \");\n }\n\n if (typeof chat.title === \"string\" && chat.title.trim().length > 0) {\n return chat.title.trim();\n }\n\n if (typeof chat.username === \"string\" && chat.username.trim().length > 0) {\n return `@${chat.username.trim()}`;\n }\n\n return \"Unknown chat\";\n}\n\nasync function main() {\n const input = JSON.parse(await Bun.stdin.text());\n const hookEventName = input.hook_event_name;\n\n if (\n hookEventName !== \"SessionStart\" &&\n hookEventName !== \"Stop\" &&\n hookEventName !== \"UserPromptSubmit\"\n ) {\n return;\n }\n\n await ensureDirectory(dirname(databasePath));\n const db = new Database(databasePath, { create: true });\n configureDatabase(db);\n applyMigrations(db);\n\n const sessionCountBefore = db.query(\"select count(*) as count from sessions\").get().count;\n\n if (typeof input.session_id !== \"string\" || input.session_id.length === 0) {\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: \"ignored\",\n reason: \"missing-session-id\",\n payload: input,\n sessionCountBefore,\n sessionCountAfter: sessionCountBefore,\n });\n return;\n }\n\n if (hookEventName === \"SessionStart\") {\n const session = upsertSession(db, input, input.source === \"resume\" ? \"resume\" : \"startup\");\n const sessionCountAfter = db.query(\"select count(*) as count from sessions\").get().count;\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: \"upsert-session\",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n });\n return;\n }\n\n if (hookEventName === \"UserPromptSubmit\") {\n const existingSession = getSession(db, input.session_id);\n const session = upsertSession(db, input, existingSession?.source ?? \"startup\");\n const sessionCountAfter = db.query(\"select count(*) as count from sessions\").get().count;\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: existingSession ? \"update-session-title\" : \"recover-session-on-prompt\",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n });\n return;\n }\n\n const settingsRow = getSettings(db);\n const existingSession = getSession(db, input.session_id);\n const generatedTitle =\n input.transcript_path == null ? parseGeneratedTitlePayload(input.last_assistant_message) : null;\n const titleTargetSession = generatedTitle ? findGeneratedTitleTargetSession(db, input) : null;\n\n if (generatedTitle && titleTargetSession) {\n updateSessionTitle(db, titleTargetSession.sessionId, generatedTitle);\n const sessionCountAfter = db.query(\"select count(*) as count from sessions\").get().count;\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: \"apply-generated-title\",\n sessionId: input.session_id,\n targetSessionId: titleTargetSession.sessionId,\n generatedTitle,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n });\n return;\n }\n\n if (existingSession?.archived) {\n const session = upsertSession(db, input, \"stop\");\n writeSession(db, session, true);\n const sessionCountAfter = db.query(\"select count(*) as count from sessions\").get().count;\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: \"ignored\",\n reason: \"archived-session\",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n });\n return;\n }\n\n const session = upsertSession(db, input, \"stop\");\n session.stopCount += 1;\n writeSession(db, session, true);\n const deliveredTelegramTargets = await sendStopNotifications(db, input);\n\n const preset = getEffectivePreset(db, input.session_id);\n const stopDecision = await applyStopDecision(\n db,\n settingsRow,\n input.session_id,\n preset,\n input,\n deliveredTelegramTargets,\n );\n const sessionCountAfter = db.query(\"select count(*) as count from sessions\").get().count;\n await appendHookDebugLog({\n type: \"hook-event\",\n hookEventName,\n action: stopDecision ? \"block-stop\" : \"allow-stop\",\n reason: existingSession ? undefined : \"recover-session-on-stop\",\n sessionId: input.session_id,\n payload: input,\n sessionCountBefore,\n sessionCountAfter,\n storedSession: session,\n preset,\n remainingTurnsBefore: stopDecision?.remainingTurnsBefore ?? null,\n remainingTurnsAfter: stopDecision?.remainingTurnsAfter ?? null,\n promptSource: stopDecision?.promptSource ?? null,\n });\n\n if (stopDecision) {\n const hookOutput = toHookStopOutput(stopDecision);\n if (hookOutput) {\n process.stdout.write(`${JSON.stringify(hookOutput)}\\n`);\n }\n }\n}\n\nawait main().catch(async (error) => {\n await appendHookDebugLog({\n type: \"hook-event\",\n action: \"uncaught-error\",\n message: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : null,\n });\n throw error;\n});\n"; +export const MANAGED_HOOK_SCRIPT_CHUNK_3 = JSON.parse( + [ + '" resolution.preset,\\n input,\\n [],\\n );\\n }\\n\\n return null;\\n }\\n\\n const ', + "maxTurns = getMaxTurns(preset);\\n if (maxTurns === null) {\\n clearRemainingTurns(db, sessionId);\\n clea", + "rSessionAwaitingReplies(db, sessionId);\\n return null;\\n }\\n\\n const remainingTurns = getRemainingTurns(d", + "b, sessionId) ?? maxTurns;\\n if (remainingTurns <= 0) {\\n clearSessionAwaitingReplies(db, sessionId);\\n ", + " return null;\\n }\\n\\n setRemainingTurns(db, sessionId, remainingTurns - 1);\\n clearSessionAwaitingReplies(d", + "b, sessionId);\\n const remotePrompt =\\n readPersistentSessionRemotePrompt(db, sessionId) ?? consumeSession", + 'RemotePrompt(db, sessionId);\\n return {\\n decision: \\"block\\",\\n reason: remotePrompt ?? renderPrompt(s', + "ettingsRow.default_prompt, remainingTurns - 1),\\n remainingTurnsBefore: remainingTurns,\\n remainingTurns", + 'After: remainingTurns - 1,\\n promptSource: remotePrompt ? \\"telegram\\" : \\"default\\",\\n };\\n}\\n\\n', + "async function sendStopNotifications(db, input) {\\n if", + ' (input.hook_event_name !== \\"Stop\\") {\\n return [];\\n }\\n\\n const message =\\n typeof input.last_assis', + 'tant_message === \\"string\\" ? input.last_assistant_message.trim() : \\"\\";\\n if (message.length === 0) {\\n ', + "return [];\\n }\\n\\n const selectedNotifications = db\\n .query(\\n `select\\n n.id,\\n n.la", + "bel,\\n n.channel,\\n n.webhook_url,\\n n.chat_id,\\n n.bot_token,\\n n.bot_url,", + "\\n n.created_at\\n from notifications n\\n inner join session_notifications sn on sn.notificati", + "on_id = n.id\\n where sn.session_id = ?\\n order by n.created_at asc, n.id asc`,\\n )\\n .all(inpu", + "t.session_id);\\n if (selectedNotifications.length === 0) {\\n return [];\\n }\\n\\n const sessionRow = db\\n ", + ' .query(\\"select session_ref, title, archived, cwd from sessions where session_id = ?\\")\\n .get(input.ses', + "sion_id);\\n if (Boolean(sessionRow?.archived)) {\\n return [];\\n }\\n const effectivePreset = getEffectiveP", + "reset(db, input.session_id);\\n const telegramTexts = buildTelegramNotificationChunks({\\n cwd: sessionRow?.c", + "wd ?? null,\\n sessionRef: sessionRow?.session_ref ?? null,\\n sessionTitle: sessionRow?.title ?? null,\\n ", + " message,\\n preset: effectivePreset,\\n telegramNotificationFooter,\\n maxLength: telegramMaxMessageLe", + "ngth,\\n });\\n\\n const deliveredTelegramTargets = [];\\n const results = await Promise.allSettled(\\n select", + 'edNotifications.map(async (notification) => {\\n if (notification.channel === \\"slack\\") {\\n const ', + "response = await fetch(notification.w", + 'ebhook_url, {\\n method: \\"POST\\",\\n headers: {\\n \\"content-type\\": \\"application/', + 'json\\",\\n },\\n body: JSON.stringify({ text: message }),\\n });\\n if (!response.', + "ok) {\\n throw new Error(`Slack notification failed with status ${response.status}`);\\n }\\n ", + ' return;\\n }\\n\\n const telegramEndpoint =\\n (typeof notification.bot_token === \\"string\\" ', + "&& notification.bot_token.length > 0\\n ? buildTelegramBotUrl(notification.bot_token)\\n : not", + 'ification.bot_url) ?? null;\\n if (!telegramEndpoint) {\\n throw new Error(\\"Telegram notification i', + 's missing a bot token.\\");\\n }\\n\\n for (const telegramText of telegramTexts) {\\n const respon', + 'se = await fetch(telegramEndpoint, {\\n method: \\"POST\\",\\n headers: {\\n \\"content', + '-type\\": \\"application/x-www-form-urlencoded;charset=UTF-8\\",\\n },\\n body: new URLSearchPara', + "ms({\\n chat_id: notification.chat_id,\\n text: telegramText,\\n }).toString(),\\n ", + " });\\n if (!response.ok) {\\n throw new Error(`Telegram notification failed with status $", + "{response.status}`);\\n }\\n\\n const payload = await response.json();\\n if (!payload?.ok ||", + ' typeof payload?.result?.message_id !== \\"number\\") {\\n throw new Error(payload?.description || \\"Tel', + 'egram notification did not return a message id.\\");\\n }\\n\\n db.query(\\n `insert into te', + "legram_delivery_receipts (\\n id,\\n notification_id,\\n session_id,\\n ", + " bot_token,\\n chat_id,\\n telegram_message_id,\\n created_at\\n ) valu", + "es (?, ?, ?, ?, ?, ?, ?)`,\\n ).run(\\n crypto.randomUUID(),\\n notification.id,\\n ", + ' input.session_id,\\n typeof notification.bot_token === \\"string\\" ? notification.bot_token : \\"\\",', + "\\n notification.chat_id,\\n payload.result.message_id,\\n nowIsoString(),\\n );", + '\\n }\\n\\n deliveredTelegramTargets.push({\\n botToken: typeof notification.bot_token === \\"stri', + 'ng\\" ? notification.bot_token : \\"\\",\\n chatId: notification.chat_id,\\n });\\n }),\\n );\\n\\n co', + 'nst failures = results.flatMap((result, index) =>\\n result.status === \\"rejected\\"\\n ? [\\n {\\', + "n notificationId: selectedNotifications[index]?.id ?? null,\\n channel: selectedNotificat", + "ions[index]?.channel ?? null,\\n error: result.reason instanceof Error ? result.reason.message : Str", + 'ing(result.reason),\\n },\\n ]\\n : [],\\n );\\n\\n await appendHookDebugLog({\\n type: \\"n', + 'otification\\",\\n hookEventName: \\"Stop\\",\\n sessionId: input.session_id,\\n deliveredCount: results.le', + "ngth - failures.length,\\n failedCount: failures.length,\\n failures,\\n });\\n\\n return deliveredTelegram", + "Targets;\\n}\\n\\nfunction getTelegramChatDisplayName(chat: {\\n title?: string | null;\\n first_name?: string | ", + "null;\\n last_name?: string | null;\\n username?: string | null;\\n}) {\\n const nameParts = [chat.first_name, ", + 'chat.last_name].filter(\\n (part): part is string => typeof part === \\"string\\" && part.trim().length > 0,\\n', + ' );\\n\\n if (nameParts.length > 0) {\\n return nameParts.join(\\" \\");\\n }\\n\\n if (typeof chat.title === \\', + '"string\\" && chat.title.trim().length > 0) {\\n return chat.title.trim();\\n }\\n\\n if (typeof chat.username', + ' === \\"string\\" && chat.username.trim().length > 0) {\\n return `@${chat.username.trim()}`;\\n }\\n\\n return', + ' \\"Unknown chat\\";\\n}\\n\\nasync function main() {\\n const input = JSON.parse(await Bun.stdin.text());\\n const', + ' hookEventName = input.hook_event_name;\\n\\n if (\\n hookEventName !== \\"SessionStart\\" &&\\n hookEventNam', + 'e !== \\"Stop\\" &&\\n hookEventName !== \\"UserPromptSubmit\\"\\n ) {\\n return;\\n }\\n\\n await ensureDirect', + "ory(dirname(databasePath));\\n const db = new Database(databasePath, { create: true });\\n configureDatabase(d", + 'b);\\n applyMigrations(db);\\n\\n const sessionCountBefore = db.query(\\"select count(*) as count from sessions\\', + '").get().count;\\n\\n if (typeof input.session_id !== \\"string\\" || input.session_id.length === 0) {\\n await', + ' appendHookDebugLog({\\n type: \\"hook-event\\",\\n hookEventName,\\n action: \\"ignored\\",\\n re', + 'ason: \\"missing-session-id\\",\\n payload: input,\\n sessionCountBefore,\\n sessionCountAfter: sess', + "ionCountBefore,\\n });\\n return;\\n }\\n\\n const runtimeState = getLoopndrollRuntimeState(db);\\n if (runt", + 'imeState !== \\"running\\") {\\n await appendHookDebugLog({\\n type: \\"hook-event\\",\\n hookEventName', + ',\\n action: \\"ignored\\",\\n reason: `runtime-${runtimeState}`,\\n sessionId: input.session_id,\\n ', + " payload: input,\\n sessionCountBefore,\\n sessionCountAfter: sessionCountBefore,\\n });\\n re", + 'turn;\\n }\\n\\n if (hookEventName === \\"SessionStart\\") {\\n const session =', + ' upsertSession(db, input, input.source === \\"resume\\" ? \\"resume\\" : \\"startup\\");\\n const sessionCountAfte', + 'r = db.query(\\"select count(*) as count from sessions\\").get().count;\\n await appendHookDebugLog({\\n t', + 'ype: \\"hook-event\\",\\n hookEventName,\\n action: \\"upsert-session\\",\\n sessionId: input.session_', + "id,\\n payload: input,\\n sessionCountBefore,\\n sessionCountAfter,\\n storedSession: session,", + '\\n });\\n return;\\n }\\n\\n if (hookEventName === \\"UserPromptSubmit\\") {\\n const existingSession = ge', + 'tSession(db, input.session_id);\\n const session = upsertSession(db, input, existingSession?.source ?? \\"sta', + 'rtup\\");\\n const sessionCountAfter = db.query(\\"select count(*) as count from sessions\\").get().count;\\n ', + ' await appendHookDebugLog({\\n type: \\"hook-event\\",\\n hookEventName,\\n action: existingSession ', + '? \\"update-session-title\\" : \\"recover-session-on-prompt\\",\\n sessionId: input.session_id,\\n payload', + ": input,\\n sessionCountBefore,\\n sessionCountAfter,\\n storedSession: session,\\n });\\n ret", + "urn;\\n }\\n\\n const settingsRow = getSettings(db);\\n const existingSession = getSession(db, input.session_id", + ");\\n const generatedTitle =\\n input.transcript_path == null ? parseGeneratedTitlePayload(input.last_assist", + "ant_message) : null;\\n const titleTargetSession = generatedTitle ? findGeneratedTitleTargetSession(db, input)", + " : null;\\n\\n if (generatedTitle && titleTargetSession) {\\n updateSessionTitle(db, titleTargetSession.sessi", + 'onId, generatedTitle);\\n const sessionCountAfter = db.query(\\"select count(*) as count from sessions\\").get', + '().count;\\n await appendHookDebugLog({\\n type: \\"hook-event\\",\\n hookEventName,\\n action: \\"', + 'apply-generated-title\\",\\n sessionId: input.session_id,\\n targetSessionId: titleTargetSession.sessio', + "nId,\\n generatedTitle,\\n payload: input,\\n sessionCountBefore,\\n sessionCountAfter,\\n }", + ');\\n return;\\n }\\n\\n if (existingSession?.archived) {\\n const session = upsertSession(db, input, \\"sto', + 'p\\");\\n writeSession(db, session, true);\\n const sessionCountAfter = db.query(\\"select count(*) as count', + ' from sessions\\").get().count;\\n await appendHookDebugLog({\\n type: \\"hook-event\\",\\n hookEventNa', + 'me,\\n action: \\"ignored\\",\\n reason: \\"archived-session\\",\\n sessionId: input.session_id,\\n ', + " payload: input,\\n sessionCountBefore,\\n sessionCountAfter,\\n storedSession: session,\\n });", + '\\n return;\\n }\\n\\n const session = upsertSession(db, input, \\"stop\\");\\n session.stopCount += 1;\\n writ', + "eSession(db, session, true);\\n const deliveredTelegramTargets = await sendStopNotifications(db, input);\\n\\n ", + "const preset = getEffectivePreset(db, input.session_id);\\n const stopDecision = await applyStopDecision(\\n ", + " db,\\n settingsRow,\\n input.session_id,\\n preset,\\n input,\\n deliveredTelegramTargets,\\n );\\n ", + ' const sessionCountAfter = db.query(\\"select count(*) as count from sessions\\").get().count;\\n await appendHo', + 'okDebugLog({\\n type: \\"hook-event\\",\\n hookEventName,\\n action: stopDecision ? \\"block-stop\\" : \\"all', + 'ow-stop\\",\\n reason: existingSession ? undefined : \\"recover-session-on-stop\\",\\n sessionId: input.sessi', + "on_id,\\n payload: input,\\n sessionCountBefore,\\n sessionCountAfter,\\n storedSession: session,\\n ", + " preset,\\n remainingTurnsBefore: stopDecision?.remainingTurnsBefore ?? null,\\n remainingTurnsAfter: stop", + "Decision?.remainingTurnsAfter ?? null,\\n promptSource: stopDecision?.promptSource ?? null,\\n });\\n\\n if (", + "stopDecision) {\\n const hookOutput = toHookStopOutput(stopDecision);\\n if (hookOutput) {\\n process.", + "stdout.write(`${JSON.stringify(hookOutput)}\\\\n`);\\n }\\n }\\n}\\n\\nawait main().catch(async (error) => {\\n a", + 'wait appendHookDebugLog({\\n type: \\"hook-event\\",\\n action: \\"uncaught-error\\",\\n message: error inst', + "anceof Error ? error.message : String(error),\\n stack: error instanceof Error ? error.stack : null,\\n });\\", + 'n throw error;\\n});\\n"', + ].join(""), +); diff --git a/src/bun/sacrificial-static-check.test.ts b/src/bun/sacrificial-static-check.test.ts new file mode 100644 index 0000000..55a8505 --- /dev/null +++ b/src/bun/sacrificial-static-check.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { buildSacrificialStaticCheckPlan } from "./sacrificial-static-check"; + +describe("buildSacrificialStaticCheckPlan", () => { + test("requires cwd and sandboxRoot and keeps check execution inside the sacrificial lane", () => { + const plan = buildSacrificialStaticCheckPlan({ + cwd: "/tmp/loopndroll", + sandboxRoot: "/tmp/sandbox", + }); + + expect(plan).toEqual({ + cwd: "/tmp/loopndroll", + sandboxRoot: "/tmp/sandbox", + command: "pnpm run check", + lane: "sacrificial", + allowHostGlobalPnpmFallback: false, + allowInstallOrMaterialization: false, + }); + }); + + test("fails closed when cwd or sandboxRoot is missing", () => { + expect(() => + buildSacrificialStaticCheckPlan({ + cwd: "", + sandboxRoot: "/tmp/sandbox", + }), + ).toThrow("cwd and sandboxRoot are required for the sacrificial static-check lane"); + + expect(() => + buildSacrificialStaticCheckPlan({ + cwd: "/tmp/loopndroll", + sandboxRoot: "", + }), + ).toThrow("cwd and sandboxRoot are required for the sacrificial static-check lane"); + }); +}); diff --git a/src/bun/sacrificial-static-check.ts b/src/bun/sacrificial-static-check.ts new file mode 100644 index 0000000..b0bfc59 --- /dev/null +++ b/src/bun/sacrificial-static-check.ts @@ -0,0 +1,40 @@ +export type SacrificialStaticCheckPlan = { + cwd: string; + sandboxRoot: string; + command: "pnpm run check"; + lane: "sacrificial"; + allowHostGlobalPnpmFallback: false; + allowInstallOrMaterialization: false; +}; + +type BuildSacrificialStaticCheckPlanInput = { + cwd: string; + sandboxRoot: string; +}; + +const REQUIRED_LANE_ERROR = + "cwd and sandboxRoot are required for the sacrificial static-check lane"; + +function normalizeRequiredPath(value: string) { + return value.trim(); +} + +export function buildSacrificialStaticCheckPlan( + input: BuildSacrificialStaticCheckPlanInput, +): SacrificialStaticCheckPlan { + const cwd = normalizeRequiredPath(input.cwd); + const sandboxRoot = normalizeRequiredPath(input.sandboxRoot); + + if (cwd.length === 0 || sandboxRoot.length === 0) { + throw new Error(REQUIRED_LANE_ERROR); + } + + return { + cwd, + sandboxRoot, + command: "pnpm run check", + lane: "sacrificial", + allowHostGlobalPnpmFallback: false, + allowInstallOrMaterialization: false, + }; +} diff --git a/src/bun/sacrificial-workspace-projection.test.ts b/src/bun/sacrificial-workspace-projection.test.ts new file mode 100644 index 0000000..1d9048d --- /dev/null +++ b/src/bun/sacrificial-workspace-projection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { buildSacrificialWorkspaceProjectionPlan } from "./sacrificial-workspace-projection"; + +describe("buildSacrificialWorkspaceProjectionPlan", () => { + test("requires cwd and sandboxRoot and derives a deterministic sandbox workspace path", () => { + const plan = buildSacrificialWorkspaceProjectionPlan({ + cwd: "/tmp/loopndroll-threadmark", + sandboxRoot: "/tmp/sandbox", + }); + + expect(plan).toEqual({ + cwd: "/tmp/loopndroll-threadmark", + sandboxRoot: "/tmp/sandbox", + workspacePath: "/tmp/sandbox/workspace/loopndroll-threadmark", + command: "pnpm run check", + lane: "sacrificial", + allowHostGlobalPnpmFallback: false, + allowInstallOrMaterialization: false, + }); + }); + + test("fails closed when cwd or sandboxRoot is missing", () => { + expect(() => + buildSacrificialWorkspaceProjectionPlan({ + cwd: "", + sandboxRoot: "/tmp/sandbox", + }), + ).toThrow("cwd and sandboxRoot are required for the sacrificial workspace projection lane"); + + expect(() => + buildSacrificialWorkspaceProjectionPlan({ + cwd: "/tmp/loopndroll-threadmark", + sandboxRoot: "", + }), + ).toThrow("cwd and sandboxRoot are required for the sacrificial workspace projection lane"); + }); +}); diff --git a/src/bun/sacrificial-workspace-projection.ts b/src/bun/sacrificial-workspace-projection.ts new file mode 100644 index 0000000..67ffde8 --- /dev/null +++ b/src/bun/sacrificial-workspace-projection.ts @@ -0,0 +1,47 @@ +export type SacrificialWorkspaceProjectionPlan = { + cwd: string; + sandboxRoot: string; + workspacePath: string; + command: "pnpm run check"; + lane: "sacrificial"; + allowHostGlobalPnpmFallback: false; + allowInstallOrMaterialization: false; +}; + +type BuildSacrificialWorkspaceProjectionPlanInput = { + cwd: string; + sandboxRoot: string; +}; + +const REQUIRED_PROJECTION_ERROR = + "cwd and sandboxRoot are required for the sacrificial workspace projection lane"; + +function normalizeRequiredPath(value: string) { + return value.trim().replace(/\/+$/, ""); +} + +function deriveWorkspaceLeafName(cwd: string) { + const segments = cwd.split("/").filter((segment) => segment.length > 0); + return segments.at(-1) ?? "workspace"; +} + +export function buildSacrificialWorkspaceProjectionPlan( + input: BuildSacrificialWorkspaceProjectionPlanInput, +): SacrificialWorkspaceProjectionPlan { + const cwd = normalizeRequiredPath(input.cwd); + const sandboxRoot = normalizeRequiredPath(input.sandboxRoot); + + if (cwd.length === 0 || sandboxRoot.length === 0) { + throw new Error(REQUIRED_PROJECTION_ERROR); + } + + return { + cwd, + sandboxRoot, + workspacePath: `${sandboxRoot}/workspace/${deriveWorkspaceLeafName(cwd)}`, + command: "pnpm run check", + lane: "sacrificial", + allowHostGlobalPnpmFallback: false, + allowInstallOrMaterialization: false, + }; +} diff --git a/src/bun/secret-store.test.ts b/src/bun/secret-store.test.ts new file mode 100644 index 0000000..7263c12 --- /dev/null +++ b/src/bun/secret-store.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSlackWebhookUrlKeychainRef, + createTelegramBotTokenKeychainRef, + getTelegramBotTokenMigrationRef, + isSlackWebhookUrlKeychainRef, + isTelegramBotTokenKeychainRef, +} from "./secret-store"; + +describe("telegram bot token keychain refs", () => { + test("creates non-secret keychain references for notification-scoped bot tokens", () => { + const ref = createTelegramBotTokenKeychainRef("notification 1"); + + expect(ref).toBe("keychain://loopndroll/telegram-bot-token/notification%201"); + expect(isTelegramBotTokenKeychainRef(ref)).toBe(true); + expect(ref).not.toContain("opaque-token-value"); + }); + + test("does not classify plain Telegram tokens as keychain references", () => { + expect(isTelegramBotTokenKeychainRef("bot-id:opaque-token-value")).toBe(false); + }); + + test("reuses one migration ref for notifications sharing a plaintext bot token", () => { + const refsByPlaintextToken = new Map(); + + const first = getTelegramBotTokenMigrationRef( + "notification-1", + "bot-id:opaque-token-value", + refsByPlaintextToken, + ); + const second = getTelegramBotTokenMigrationRef( + "notification-2", + "bot-id:opaque-token-value", + refsByPlaintextToken, + ); + + expect(first).toEqual({ + ref: "keychain://loopndroll/telegram-bot-token/notification-1", + shouldStore: true, + }); + expect(second).toEqual({ + ref: first.ref, + shouldStore: false, + }); + }); +}); + +describe("slack webhook URL keychain refs", () => { + test("creates non-secret keychain references for notification-scoped webhook URLs", () => { + const ref = createSlackWebhookUrlKeychainRef("notification 1"); + + expect(ref).toBe("keychain://loopndroll/slack-webhook-url/notification%201"); + expect(isSlackWebhookUrlKeychainRef(ref)).toBe(true); + expect(ref).not.toContain("https://hooks.slack.com/services/"); + }); + + test("does not classify plain Slack webhook URLs as keychain references", () => { + expect(isSlackWebhookUrlKeychainRef("https://hooks.slack.com/services/test")).toBe(false); + }); +}); diff --git a/src/bun/secret-store.ts b/src/bun/secret-store.ts new file mode 100644 index 0000000..caffec7 --- /dev/null +++ b/src/bun/secret-store.ts @@ -0,0 +1,231 @@ +import { spawnSync } from "node:child_process"; + +const TELEGRAM_BOT_TOKEN_SERVICE = "loopndroll.telegram.bot-token"; +const TELEGRAM_BOT_TOKEN_REF_PREFIX = "keychain://loopndroll/telegram-bot-token/"; +const SLACK_WEBHOOK_URL_SERVICE = "loopndroll.slack.webhook-url"; +const SLACK_WEBHOOK_URL_REF_PREFIX = "keychain://loopndroll/slack-webhook-url/"; + +function encodeKeychainAccount(notificationId: string) { + return notificationId.trim(); +} + +function parseTelegramBotTokenKeychainRef(value: string) { + const trimmed = value.trim(); + if (!trimmed.startsWith(TELEGRAM_BOT_TOKEN_REF_PREFIX)) { + return null; + } + + const account = decodeURIComponent(trimmed.slice(TELEGRAM_BOT_TOKEN_REF_PREFIX.length)); + return account.length > 0 ? account : null; +} + +function parseSlackWebhookUrlKeychainRef(value: string) { + const trimmed = value.trim(); + if (!trimmed.startsWith(SLACK_WEBHOOK_URL_REF_PREFIX)) { + return null; + } + + const account = decodeURIComponent(trimmed.slice(SLACK_WEBHOOK_URL_REF_PREFIX.length)); + return account.length > 0 ? account : null; +} + +export function isTelegramBotTokenKeychainRef(value: string | null | undefined) { + return typeof value === "string" && parseTelegramBotTokenKeychainRef(value) !== null; +} + +export function isSlackWebhookUrlKeychainRef(value: string | null | undefined) { + return typeof value === "string" && parseSlackWebhookUrlKeychainRef(value) !== null; +} + +export function createTelegramBotTokenKeychainRef(notificationId: string) { + return `${TELEGRAM_BOT_TOKEN_REF_PREFIX}${encodeURIComponent(encodeKeychainAccount(notificationId))}`; +} + +export function createSlackWebhookUrlKeychainRef(notificationId: string) { + return `${SLACK_WEBHOOK_URL_REF_PREFIX}${encodeURIComponent(encodeKeychainAccount(notificationId))}`; +} + +export function getTelegramBotTokenMigrationRef( + notificationId: string, + botToken: string, + refsByPlaintextToken: Map, +) { + const normalizedBotToken = botToken.trim(); + const existingRef = refsByPlaintextToken.get(normalizedBotToken); + if (existingRef) { + return { + ref: existingRef, + shouldStore: false, + }; + } + + const ref = createTelegramBotTokenKeychainRef(notificationId); + refsByPlaintextToken.set(normalizedBotToken, ref); + return { + ref, + shouldStore: true, + }; +} + +function runSecurityCommand(args: string[]) { + const result = spawnSync("/usr/bin/security", args, { + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + + return { + ok: result.status === 0, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + }; +} + +function storeSecretInKeychain(input: { + notificationId: string; + secret: string; + emptyMessage: string; + service: string; + failureMessage: string; + refFactory: (notificationId: string) => string; +}) { + const secret = input.secret.trim(); + if (secret.length === 0) { + throw new Error(input.emptyMessage); + } + + if (process.platform !== "darwin") { + return secret; + } + + const account = encodeKeychainAccount(input.notificationId); + const result = runSecurityCommand([ + "add-generic-password", + "-a", + account, + "-s", + input.service, + "-w", + secret, + "-U", + ]); + if (!result.ok) { + throw new Error(input.failureMessage); + } + + return input.refFactory(input.notificationId); +} + +function resolveSecretFromKeychain(input: { + valueOrRef: string; + parseRef: (value: string) => string | null; + service: string; + unavailableMessage: string; + readFailureMessage: string; + emptyMessage: string; +}) { + const value = input.valueOrRef.trim(); + const account = input.parseRef(value); + if (account === null) { + return value; + } + + if (process.platform !== "darwin") { + throw new Error(input.unavailableMessage); + } + + const result = runSecurityCommand([ + "find-generic-password", + "-a", + account, + "-s", + input.service, + "-w", + ]); + if (!result.ok) { + throw new Error(input.readFailureMessage); + } + + const secret = result.stdout.trim(); + if (secret.length === 0) { + throw new Error(input.emptyMessage); + } + + return secret; +} + +function deleteSecretFromKeychain(input: { + valueOrRef: string | null | undefined; + parseRef: (value: string) => string | null; + service: string; +}) { + if (typeof input.valueOrRef !== "string" || process.platform !== "darwin") { + return; + } + + const account = input.parseRef(input.valueOrRef); + if (account === null) { + return; + } + + runSecurityCommand(["delete-generic-password", "-a", account, "-s", input.service]); +} + +export function storeTelegramBotTokenInKeychain(notificationId: string, botToken: string) { + return storeSecretInKeychain({ + notificationId, + secret: botToken, + emptyMessage: "Telegram bot token is required.", + service: TELEGRAM_BOT_TOKEN_SERVICE, + failureMessage: "Could not store Telegram bot token in macOS Keychain.", + refFactory: createTelegramBotTokenKeychainRef, + }); +} + +export function storeSlackWebhookUrlInKeychain(notificationId: string, webhookUrl: string) { + return storeSecretInKeychain({ + notificationId, + secret: webhookUrl, + emptyMessage: "Slack webhook URL is required.", + service: SLACK_WEBHOOK_URL_SERVICE, + failureMessage: "Could not store Slack webhook URL in macOS Keychain.", + refFactory: createSlackWebhookUrlKeychainRef, + }); +} + +export function resolveTelegramBotToken(botTokenOrRef: string) { + return resolveSecretFromKeychain({ + valueOrRef: botTokenOrRef, + parseRef: parseTelegramBotTokenKeychainRef, + service: TELEGRAM_BOT_TOKEN_SERVICE, + unavailableMessage: "Telegram bot token is stored in Keychain, but Keychain is unavailable.", + readFailureMessage: "Could not read Telegram bot token from macOS Keychain.", + emptyMessage: "Telegram bot token in macOS Keychain is empty.", + }); +} + +export function resolveSlackWebhookUrl(webhookUrlOrRef: string) { + return resolveSecretFromKeychain({ + valueOrRef: webhookUrlOrRef, + parseRef: parseSlackWebhookUrlKeychainRef, + service: SLACK_WEBHOOK_URL_SERVICE, + unavailableMessage: "Slack webhook URL is stored in Keychain, but Keychain is unavailable.", + readFailureMessage: "Could not read Slack webhook URL from macOS Keychain.", + emptyMessage: "Slack webhook URL in macOS Keychain is empty.", + }); +} + +export function deleteTelegramBotTokenFromKeychain(botTokenOrRef: string | null | undefined) { + deleteSecretFromKeychain({ + valueOrRef: botTokenOrRef, + parseRef: parseTelegramBotTokenKeychainRef, + service: TELEGRAM_BOT_TOKEN_SERVICE, + }); +} + +export function deleteSlackWebhookUrlFromKeychain(webhookUrlOrRef: string | null | undefined) { + deleteSecretFromKeychain({ + valueOrRef: webhookUrlOrRef, + parseRef: parseSlackWebhookUrlKeychainRef, + service: SLACK_WEBHOOK_URL_SERVICE, + }); +} diff --git a/src/bun/startup-recovery.test.ts b/src/bun/startup-recovery.test.ts new file mode 100644 index 0000000..f48fe46 --- /dev/null +++ b/src/bun/startup-recovery.test.ts @@ -0,0 +1,209 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import type { LoopndrollPaths } from "./loopndroll-core"; +import { + clearStartupRecoveryMarker, + resetActiveLoopStateInDatabase, + resetActiveLoopStateOnStartup, +} from "./startup-recovery"; + +async function createTestPaths() { + const appDirectoryPath = await mkdtemp(join(tmpdir(), "loopndroll-startup-recovery-")); + const paths = { + appDirectoryPath, + binDirectoryPath: join(appDirectoryPath, "bin"), + stateDirectoryPath: join(appDirectoryPath, "state"), + logsDirectoryPath: join(appDirectoryPath, "logs"), + databasePath: join(appDirectoryPath, "app.db"), + managedHookPath: join(appDirectoryPath, "bin", "loopndroll-hook"), + hookRemovalWatchLockPath: join(appDirectoryPath, "state", "hook-removal-watch.lock"), + startupRecoveryMarkerPath: join(appDirectoryPath, "state", "startup-runtime.marker.json"), + hookDebugLogPath: join(appDirectoryPath, "logs", "hooks-debug.jsonl"), + codexDirectoryPath: join(appDirectoryPath, ".codex"), + codexConfigPath: join(appDirectoryPath, ".codex", "config.toml"), + codexHooksPath: join(appDirectoryPath, ".codex", "hooks.json"), + } satisfies LoopndrollPaths; + + return { + paths, + async cleanup() { + await rm(appDirectoryPath, { recursive: true, force: true }); + }, + }; +} + +function createRecoverySchema(db: Database) { + db.exec(` + create table settings ( + id integer primary key, + global_preset text + ); + insert into settings (id, global_preset) values (1, 'await-reply'); + + create table sessions ( + thread_id text primary key, + preset text, + preset_overridden integer not null default 0, + active_since text, + archived integer not null default 0 + ); + + create table session_runtime ( + thread_id text primary key + ); + + create table session_awaiting_replies ( + thread_id text not null + ); + + create table session_remote_prompts ( + thread_id text not null, + delivery_mode text not null, + primary key(thread_id, delivery_mode) + ); + `); +} + +describe("resetActiveLoopStateInDatabase", () => { + test("clears inherited active loop state without deleting sessions", () => { + const db = new Database(":memory:"); + createRecoverySchema(db); + db.query( + `insert into sessions (thread_id, preset, preset_overridden, active_since, archived) values + ('active', 'await-reply', 0, '2026-04-30T00:00:00.000Z', 0), + ('archived', 'infinite', 0, '2026-04-30T00:00:00.000Z', 1)`, + ).run(); + db.query("insert into session_runtime (thread_id) values ('active')").run(); + db.query("insert into session_awaiting_replies (thread_id) values ('active')").run(); + db.query( + "insert into session_remote_prompts (thread_id, delivery_mode) values ('active', 'once')", + ).run(); + + const summary = resetActiveLoopStateInDatabase(db); + + expect(summary).toMatchObject({ + resetApplied: true, + globalPresetCleared: true, + sessionModesCleared: 1, + runtimeRowsDeleted: 1, + awaitingRepliesDeleted: 1, + remotePromptsDeleted: 1, + }); + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect( + db + .query( + "select preset, preset_overridden, active_since from sessions where thread_id = 'active'", + ) + .get(), + ).toEqual({ + preset: null, + preset_overridden: 1, + active_since: null, + }); + expect(db.query("select count(*) as count from sessions").get()).toEqual({ count: 2 }); + expect(db.query("select count(*) as count from session_runtime").get()).toEqual({ count: 0 }); + db.close(); + }); + + test("does nothing when there is no inherited active state", () => { + const db = new Database(":memory:"); + createRecoverySchema(db); + db.query("update settings set global_preset = null where id = 1").run(); + + const summary = resetActiveLoopStateInDatabase(db); + + expect(summary.resetApplied).toBe(false); + db.close(); + }); +}); + +describe("resetActiveLoopStateOnStartup", () => { + test("preserves active modes during a normal launch with no stale runtime marker", async () => { + const { paths, cleanup } = await createTestPaths(); + try { + const db = new Database(paths.databasePath); + createRecoverySchema(db); + db.query( + `insert into sessions (thread_id, preset, preset_overridden, active_since, archived) + values ('active', 'await-reply', 0, '2026-04-30T00:00:00.000Z', 0)`, + ).run(); + db.close(); + + const summary = resetActiveLoopStateOnStartup(paths); + const reopened = new Database(paths.databasePath); + + expect(summary).toMatchObject({ + resetApplied: false, + staleRuntimeMarkerFound: false, + }); + expect(existsSync(paths.startupRecoveryMarkerPath)).toBe(true); + expect(reopened.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: "await-reply", + }); + expect( + reopened.query("select preset from sessions where thread_id = 'active'").get(), + ).toEqual({ preset: "await-reply" }); + reopened.close(); + } finally { + clearStartupRecoveryMarker(paths); + await cleanup(); + } + }); + + test("clears inherited active modes after a stale runtime marker", async () => { + const { paths, cleanup } = await createTestPaths(); + try { + const db = new Database(paths.databasePath); + createRecoverySchema(db); + db.query( + `insert into sessions (thread_id, preset, preset_overridden, active_since, archived) + values ('active', 'await-reply', 0, '2026-04-30T00:00:00.000Z', 0)`, + ).run(); + await mkdir(paths.stateDirectoryPath, { recursive: true }); + await writeFile(paths.startupRecoveryMarkerPath, "{}\n", "utf8"); + + const summary = resetActiveLoopStateOnStartup(paths, db); + + expect(summary).toMatchObject({ + resetApplied: true, + staleRuntimeMarkerFound: true, + globalPresetCleared: true, + sessionModesCleared: 1, + }); + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect(db.query("select preset from sessions where thread_id = 'active'").get()).toEqual({ + preset: null, + }); + expect(existsSync(paths.startupRecoveryMarkerPath)).toBe(true); + db.close(); + } finally { + clearStartupRecoveryMarker(paths); + await cleanup(); + } + }); + + test("removes the runtime marker during graceful shutdown cleanup", async () => { + const { paths, cleanup } = await createTestPaths(); + try { + const db = new Database(paths.databasePath); + createRecoverySchema(db); + db.close(); + + resetActiveLoopStateOnStartup(paths); + clearStartupRecoveryMarker(paths); + + expect(existsSync(paths.startupRecoveryMarkerPath)).toBe(false); + } finally { + await cleanup(); + } + }); +}); diff --git a/src/bun/startup-recovery.ts b/src/bun/startup-recovery.ts new file mode 100644 index 0000000..8e97647 --- /dev/null +++ b/src/bun/startup-recovery.ts @@ -0,0 +1,145 @@ +import type { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { getLoopndrollDatabase } from "./db/client"; +import { getLoopndrollPaths, nowIsoString, type LoopndrollPaths } from "./loopndroll-core"; + +export type StartupRecoverySummary = { + resetApplied: boolean; + checkedAt: string; + staleRuntimeMarkerFound: boolean; + globalPresetCleared: boolean; + sessionModesCleared: number; + runtimeRowsDeleted: number; + awaitingRepliesDeleted: number; + remotePromptsDeleted: number; +}; + +function getCount(client: Database, sql: string) { + const row = client.query(sql).get() as { count?: number } | null; + return typeof row?.count === "number" ? row.count : 0; +} + +function hasGlobalPreset(client: Database) { + const row = client.query("select global_preset from settings where id = 1").get() as { + global_preset?: string | null; + } | null; + return typeof row?.global_preset === "string" && row.global_preset.trim().length > 0; +} + +function buildNoResetSummary(staleRuntimeMarkerFound: boolean): StartupRecoverySummary { + return { + resetApplied: false, + checkedAt: nowIsoString(), + staleRuntimeMarkerFound, + globalPresetCleared: false, + sessionModesCleared: 0, + runtimeRowsDeleted: 0, + awaitingRepliesDeleted: 0, + remotePromptsDeleted: 0, + }; +} + +function writeStartupRecoveryMarker(paths: LoopndrollPaths) { + mkdirSync(dirname(paths.startupRecoveryMarkerPath), { recursive: true }); + writeFileSync( + paths.startupRecoveryMarkerPath, + `${JSON.stringify({ + pid: process.pid, + started_at: nowIsoString(), + database_path: paths.databasePath, + })}\n`, + "utf8", + ); +} + +export function clearStartupRecoveryMarker(paths = getLoopndrollPaths()) { + rmSync(paths.startupRecoveryMarkerPath, { force: true }); +} + +export function resetActiveLoopStateInDatabase(client: Database): StartupRecoverySummary { + const checkedAt = nowIsoString(); + const globalPresetCleared = hasGlobalPreset(client); + const sessionModesCleared = getCount( + client, + `select count(*) as count + from sessions + where archived = 0 + and (preset is not null or active_since is not null)`, + ); + const runtimeRowsDeleted = getCount(client, "select count(*) as count from session_runtime"); + const awaitingRepliesDeleted = getCount( + client, + "select count(*) as count from session_awaiting_replies", + ); + const remotePromptsDeleted = getCount( + client, + "select count(*) as count from session_remote_prompts", + ); + + const resetApplied = + globalPresetCleared || + sessionModesCleared > 0 || + runtimeRowsDeleted > 0 || + awaitingRepliesDeleted > 0 || + remotePromptsDeleted > 0; + + if (!resetApplied) { + return { + resetApplied, + checkedAt, + staleRuntimeMarkerFound: true, + globalPresetCleared, + sessionModesCleared, + runtimeRowsDeleted, + awaitingRepliesDeleted, + remotePromptsDeleted, + }; + } + + client.transaction(() => { + client.query("update settings set global_preset = null where id = 1").run(); + client + .query( + `update sessions + set preset = null, + preset_overridden = 1, + active_since = null + where archived = 0 + and (preset is not null or active_since is not null)`, + ) + .run(); + client.query("delete from session_runtime").run(); + client.query("delete from session_awaiting_replies").run(); + client.query("delete from session_remote_prompts").run(); + })(); + + return { + resetApplied, + checkedAt, + staleRuntimeMarkerFound: true, + globalPresetCleared, + sessionModesCleared, + runtimeRowsDeleted, + awaitingRepliesDeleted, + remotePromptsDeleted, + }; +} + +export function resetActiveLoopStateOnStartup(paths = getLoopndrollPaths(), client?: Database) { + const staleRuntimeMarkerFound = existsSync(paths.startupRecoveryMarkerPath); + if (!staleRuntimeMarkerFound) { + writeStartupRecoveryMarker(paths); + return buildNoResetSummary(false); + } + + const activeClient = client ?? getLoopndrollDatabase(paths.databasePath).client; + const summary = resetActiveLoopStateInDatabase(activeClient); + writeStartupRecoveryMarker(paths); + + if (summary.resetApplied) { + console.warn("Loopndroll startup reset cleared inherited active loop state.", summary); + } + + return summary; +} diff --git a/src/bun/telegram-bridge-context.ts b/src/bun/telegram-bridge-context.ts new file mode 100644 index 0000000..c6d61a0 --- /dev/null +++ b/src/bun/telegram-bridge-context.ts @@ -0,0 +1,94 @@ +import { type Database } from "bun:sqlite"; +import { formatTelegramSessionLabel } from "./telegram-output"; +import { appendHookDebugLog, type LoopndrollPaths } from "./loopndroll-core"; +import { + collectTelegramChatsFromUpdates, + type TelegramInboundMessage, + type TelegramUpdate, + upsertKnownTelegramChats, +} from "./telegram-utils"; + +export type TelegramBridgeUpdateContext = { + paths: LoopndrollPaths; + db: Database; + botToken: string; + update: TelegramUpdate; + message: TelegramInboundMessage; + trimmedText: string; + chatId: string; +}; + +export function createTelegramBridgeUpdateContext( + paths: LoopndrollPaths, + db: Database, + botToken: string, + update: TelegramUpdate, +): TelegramBridgeUpdateContext | null { + const message = update.message; + if (!message || typeof message.text !== "string") { + return null; + } + + const trimmedText = message.text.trim(); + if (trimmedText.length === 0) { + return null; + } + + const chatId = + typeof message.chat?.id === "number" || typeof message.chat?.id === "string" + ? String(message.chat.id) + : null; + if (!chatId) { + return null; + } + + return { paths, db, botToken, update, message, trimmedText, chatId }; +} + +export async function prepareTelegramBridgeUpdate( + context: TelegramBridgeUpdateContext, + isAuthorizedTelegramBridgeChat: (db: Database, botToken: string, chatId: string) => boolean, +) { + if (context.message.chat?.type !== "private") { + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "ignored-message", + reason: "non-dm-chat", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + }); + return false; + } + + if (!isAuthorizedTelegramBridgeChat(context.db, context.botToken, context.chatId)) { + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "ignored-message", + reason: "unauthorized-chat", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + }); + return false; + } + + const discoveredChats = collectTelegramChatsFromUpdates([context.update]); + if (discoveredChats.length > 0) { + upsertKnownTelegramChats(context.db, context.botToken, discoveredChats); + } + + return true; +} + +export function formatTelegramTargetSessionLabel(targetSession: { + cwd?: string | null; + sessionRef: string; + title: string | null; +}) { + return formatTelegramSessionLabel({ + cwd: targetSession.cwd ?? null, + sessionRef: targetSession.sessionRef, + title: targetSession.title, + }); +} diff --git a/src/bun/telegram-bridge-polling.test.ts b/src/bun/telegram-bridge-polling.test.ts new file mode 100644 index 0000000..5fba4ee --- /dev/null +++ b/src/bun/telegram-bridge-polling.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import type { LoopndrollPaths } from "./loopndroll-core"; +import { pollTelegramBridgeBotToken } from "./telegram-bridge"; +import type { TelegramUpdate } from "./telegram-utils"; + +async function createTestPaths() { + const appDirectoryPath = await mkdtemp(join(tmpdir(), "loopndroll-telegram-polling-")); + const paths = { + appDirectoryPath, + binDirectoryPath: join(appDirectoryPath, "bin"), + stateDirectoryPath: join(appDirectoryPath, "state"), + logsDirectoryPath: join(appDirectoryPath, "logs"), + databasePath: join(appDirectoryPath, "app.db"), + managedHookPath: join(appDirectoryPath, "bin", "loopndroll-hook"), + hookRemovalWatchLockPath: join(appDirectoryPath, "state", "hook-removal-watch.lock"), + startupRecoveryMarkerPath: join(appDirectoryPath, "state", "startup-runtime.marker.json"), + hookDebugLogPath: join(appDirectoryPath, "logs", "hooks-debug.jsonl"), + codexDirectoryPath: join(appDirectoryPath, ".codex"), + codexConfigPath: join(appDirectoryPath, ".codex", "config.toml"), + codexHooksPath: join(appDirectoryPath, ".codex", "hooks.json"), + } satisfies LoopndrollPaths; + + return { + paths, + async cleanup() { + await rm(appDirectoryPath, { recursive: true, force: true }); + }, + }; +} + +function createPollingSchema(db: Database) { + db.exec(` + create table telegram_update_cursors ( + bot_token text primary key, + last_update_id integer not null, + updated_at text not null + ); + `); +} + +function getCursor(db: Database, botToken: string) { + return db + .query("select last_update_id from telegram_update_cursors where bot_token = ?") + .get(botToken) as { last_update_id: number } | null; +} + +describe("pollTelegramBridgeBotToken", () => { + test("continues after a bad update and advances the cursor past the fetched batch", async () => { + const { paths, cleanup } = await createTestPaths(); + const db = new Database(":memory:"); + createPollingSchema(db); + const processed: number[] = []; + const logs: Array> = []; + const updates: TelegramUpdate[] = [{ update_id: 10 }, { update_id: 11 }, { update_id: 12 }]; + + try { + await pollTelegramBridgeBotToken(paths, db, "bot-token", { + fetchUpdates: async () => updates, + processUpdate: async (_paths, _db, _botToken, update) => { + if (update.update_id === 11) { + throw new Error("poison update"); + } + processed.push(update.update_id ?? -1); + }, + log: async (_paths, entry) => { + logs.push(entry); + }, + }); + + expect(processed).toEqual([10, 12]); + expect(getCursor(db, "bot-token")).toEqual({ last_update_id: 12 }); + expect(logs).toMatchObject([ + { + action: "poll-update-error", + updateId: 11, + }, + ]); + } finally { + db.close(); + await cleanup(); + } + }); + + test("does not advance the cursor when fetching updates fails", async () => { + const { paths, cleanup } = await createTestPaths(); + const db = new Database(":memory:"); + createPollingSchema(db); + db.query( + "insert into telegram_update_cursors (bot_token, last_update_id, updated_at) values ('bot-token', 41, '2026-04-30T00:00:00.000Z')", + ).run(); + const logs: Array> = []; + + try { + await pollTelegramBridgeBotToken(paths, db, "bot-token", { + fetchUpdates: async () => { + throw new Error("bad token"); + }, + log: async (_paths, entry) => { + logs.push(entry); + }, + }); + + expect(getCursor(db, "bot-token")).toEqual({ last_update_id: 41 }); + expect(logs).toMatchObject([ + { + action: "poll-token-error", + error: "bad token", + }, + ]); + } finally { + db.close(); + await cleanup(); + } + }); +}); diff --git a/src/bun/telegram-bridge-runtime-guard.test.ts b/src/bun/telegram-bridge-runtime-guard.test.ts new file mode 100644 index 0000000..a312757 --- /dev/null +++ b/src/bun/telegram-bridge-runtime-guard.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildTelegramPromptReceivedText, + buildTelegramWorkingAckText, + getTelegramRemotePromptDeliveryMode, +} from "./telegram-control"; +import { isTelegramCommandAllowedInRuntimeState } from "./telegram-bridge"; + +describe("telegram bridge await-reply runtime guard", () => { + test("keeps one-shot delivery for await-reply freeform replies", () => { + expect(getTelegramRemotePromptDeliveryMode("await-reply")).toBe("once"); + }); + + test("keeps the received acknowledgement contract for await-reply /reply fallback", () => { + const targetSession = { + cwd: "/Users/example/Documents/ChiefOfStaff", + sessionRef: "c22", + title: "Fix bridge", + }; + + expect(buildTelegramPromptReceivedText(targetSession)).toBe( + ["Reply queued for next Codex stop", "[ChiefOfStaff] [C22]", "Thread: Fix bridge"].join("\n"), + ); + expect(buildTelegramWorkingAckText(targetSession)).toBe( + ["Reply delivered to Codex", "[ChiefOfStaff] [C22]", "Thread: Fix bridge"].join("\n"), + ); + }); +}); + +describe("telegram bridge inactive runtime command policy", () => { + test("keeps administrative Telegram commands available while stopped", () => { + expect(isTelegramCommandAllowedInRuntimeState("stopped", "status")).toBe(true); + expect(isTelegramCommandAllowedInRuntimeState("stopped", "help")).toBe(true); + expect(isTelegramCommandAllowedInRuntimeState("stopped", "list")).toBe(true); + expect(isTelegramCommandAllowedInRuntimeState("stopped", "mode")).toBe(true); + expect(isTelegramCommandAllowedInRuntimeState("stopped", "failsafe")).toBe(true); + }); + + test("blocks freeform Telegram input while stopped", () => { + expect(isTelegramCommandAllowedInRuntimeState("stopped", null)).toBe(false); + expect(isTelegramCommandAllowedInRuntimeState("stopped", "unknown")).toBe(false); + }); +}); diff --git a/src/bun/telegram-bridge-session-store.test.ts b/src/bun/telegram-bridge-session-store.test.ts new file mode 100644 index 0000000..1e67b08 --- /dev/null +++ b/src/bun/telegram-bridge-session-store.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; + +import { + disableAllTelegramSessionsViaFailsafe, + disableTelegramSessionViaFailsafe, + findLatestAwaitingTelegramSessionId, + findLatestDeliveredTelegramSessionId, + findTelegramSessionByRef, + getTelegramSessionBridgeStates, + listRegisteredTelegramSessions, +} from "./telegram-bridge-session-store"; + +function createTelegramBridgeSchema(db: Database) { + db.exec(` + create table settings ( + id integer primary key, + global_preset text + ); + insert into settings (id, global_preset) values (1, null); + + create table notifications ( + id text primary key, + channel text not null, + bot_token text, + chat_id text + ); + + create table sessions ( + thread_id text primary key, + session_ref text not null, + cwd text, + thread_name text, + transcript_path text, + last_assistant_message text, + first_seen_at text not null, + last_seen_at text not null, + active_since text, + preset text, + preset_overridden integer not null default 0, + archived integer not null default 0 + ); + + create table session_notifications ( + thread_id text not null, + notification_id text not null + ); + + create table session_awaiting_replies ( + thread_id text not null, + bot_token text not null, + chat_id text not null, + started_at text not null + ); + + create table session_runtime ( + thread_id text primary key, + started_at text not null + ); + + create table session_remote_prompts ( + thread_id text not null, + source text not null, + delivery_mode text not null, + prompt_text text not null, + telegram_chat_id text, + telegram_message_id integer, + created_at text not null, + primary key(thread_id, delivery_mode) + ); + + create table telegram_delivery_receipts ( + id text primary key, + notification_id text, + thread_id text not null, + bot_token text not null, + chat_id text not null, + telegram_message_id integer not null, + created_at text not null + ); + `); +} + +function insertFailsafeFixtureSessions(db: Database) { + db.query( + `insert into sessions ( + thread_id, + session_ref, + cwd, + thread_name, + transcript_path, + last_assistant_message, + first_seen_at, + last_seen_at, + active_since, + preset, + preset_overridden, + archived + ) values + ('thr_target', 'C12', '/tmp/project', 'Target', null, null, '2026-04-23T10:00:00.000Z', '2026-04-23T10:00:00.000Z', '2026-04-23T10:00:00.000Z', 'await-reply', 1, 0), + ('thr_other', 'C13', '/tmp/project', 'Other', null, null, '2026-04-23T11:00:00.000Z', '2026-04-23T11:00:00.000Z', '2026-04-23T11:00:00.000Z', 'await-reply', 1, 0)`, + ).run(); +} + +function insertFailsafeFixtureRemoteState(db: Database) { + db.query( + `insert into session_awaiting_replies (thread_id, bot_token, chat_id, started_at) values + ('thr_target', 'bot', 'chat', '2026-04-23T10:01:00.000Z'), + ('thr_other', 'bot', 'chat', '2026-04-23T11:01:00.000Z')`, + ).run(); + db.query( + `insert into session_runtime (thread_id, started_at) values + ('thr_target', '2026-04-23T10:02:00.000Z'), + ('thr_other', '2026-04-23T11:02:00.000Z')`, + ).run(); + db.query( + `insert into session_remote_prompts (thread_id, source, delivery_mode, prompt_text, created_at) values + ('thr_target', 'telegram', 'once', 'target prompt', '2026-04-23T10:03:00.000Z'), + ('thr_other', 'telegram', 'once', 'other prompt', '2026-04-23T11:03:00.000Z')`, + ).run(); +} + +function insertTelegramNotification(db: Database) { + db.query( + "insert into notifications (id, channel, bot_token, chat_id) values ('n1', 'telegram', 'bot', 'chat')", + ).run(); +} + +function insertOtherTelegramNotification(db: Database) { + db.query( + "insert into notifications (id, channel, bot_token, chat_id) values ('n2', 'telegram', 'bot', 'other-chat')", + ).run(); +} + +function attachTelegramNotification(db: Database, threadIds: string[]) { + const values = threadIds.map((threadId) => `('${threadId}', 'n1')`).join(","); + db.query(`insert into session_notifications (thread_id, notification_id) values ${values}`).run(); +} + +function attachOtherTelegramNotification(db: Database, threadIds: string[]) { + const values = threadIds.map((threadId) => `('${threadId}', 'n2')`).join(","); + db.query(`insert into session_notifications (thread_id, notification_id) values ${values}`).run(); +} + +function insertRegisteredSession( + db: Database, + input: { threadId: string; sessionRef: string; cwd: string; title: string; seenAt: string }, +) { + db.query( + `insert into sessions ( + thread_id, + session_ref, + cwd, + thread_name, + transcript_path, + last_assistant_message, + first_seen_at, + last_seen_at, + active_since, + preset, + preset_overridden, + archived + ) values (?, ?, ?, ?, null, null, ?, ?, null, 'await-reply', 1, 0)`, + ).run(input.threadId, input.sessionRef, input.cwd, input.title, input.seenAt, input.seenAt); +} + +describe("telegram bridge session store", () => { + test("lists registered sessions from the current thread_id/thread_name schema", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + + insertTelegramNotification(db); + insertRegisteredSession(db, { + threadId: "thr_123", + sessionRef: "C22", + cwd: "/tmp/project", + title: "Fix hook lifecycle", + seenAt: "2026-04-23T10:00:00.000Z", + }); + attachTelegramNotification(db, ["thr_123"]); + + const sessions = listRegisteredTelegramSessions(db, "bot", "chat"); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + sessionId: "thr_123", + sessionRef: "C22", + cwd: "/tmp/project", + title: "Fix hook lifecycle", + effectivePreset: "await-reply", + }); + + expect(findTelegramSessionByRef(db, "bot", "chat", "c22")).toEqual({ + sessionId: "thr_123", + sessionRef: "C22", + cwd: "/tmp/project", + title: "Fix hook lifecycle", + }); + }); + + test("hides internal thread-name artifacts from Telegram list and ref lookup", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + + insertTelegramNotification(db); + insertRegisteredSession(db, { + threadId: "thr_internal", + sessionRef: "C30", + cwd: "/tmp/project", + title: "AGENTS.md instructions for /tmp/project", + seenAt: "2026-04-23T10:00:00.000Z", + }); + insertRegisteredSession(db, { + threadId: "thr_real", + sessionRef: "C31", + cwd: "/tmp/memories", + title: "Memory Writing Agent: Phase 2 (Consolidation)", + seenAt: "2026-04-23T11:00:00.000Z", + }); + attachTelegramNotification(db, ["thr_internal", "thr_real"]); + + const sessions = listRegisteredTelegramSessions(db, "bot", "chat"); + + expect(sessions.map((session) => session.sessionId)).toEqual(["thr_real"]); + expect(findTelegramSessionByRef(db, "bot", "chat", "C30")).toBeNull(); + expect(findTelegramSessionByRef(db, "bot", "chat", "C31")).toMatchObject({ + sessionId: "thr_real", + title: "Memory Writing Agent: Phase 2 (Consolidation)", + }); + }); + + test("finds the latest awaiting reply using the current thread_id schema", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + + db.query( + `insert into sessions ( + thread_id, + session_ref, + cwd, + thread_name, + transcript_path, + last_assistant_message, + first_seen_at, + last_seen_at, + active_since, + preset, + preset_overridden, + archived + ) values + ('thr_old', 'C11', '/tmp/project', 'Old', null, null, '2026-04-23T09:00:00.000Z', '2026-04-23T09:00:00.000Z', null, 'await-reply', 0, 0), + ('thr_new', 'C12', '/tmp/project', 'New', null, null, '2026-04-23T10:00:00.000Z', '2026-04-23T10:00:00.000Z', null, 'await-reply', 0, 0)`, + ).run(); + db.query( + `insert into session_awaiting_replies (thread_id, bot_token, chat_id, started_at) values + ('thr_old', 'bot', 'chat', '2026-04-23T09:00:00.000Z'), + ('thr_new', 'bot', 'chat', '2026-04-23T10:00:00.000Z')`, + ).run(); + + expect(findLatestAwaitingTelegramSessionId(db, "bot", "chat")).toBe("thr_new"); + }); +}); + +describe("telegram bridge loose reply fallback", () => { + test("finds the latest delivered Telegram session for loose replies", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + + db.query( + `insert into sessions ( + thread_id, + session_ref, + cwd, + thread_name, + transcript_path, + last_assistant_message, + first_seen_at, + last_seen_at, + active_since, + preset, + preset_overridden, + archived + ) values + ('thr_old', 'C11', '/tmp/project', 'Old', null, null, '2026-04-23T09:00:00.000Z', '2026-04-23T09:00:00.000Z', null, 'await-reply', 1, 0), + ('thr_new', 'C12', '/tmp/project', 'New', null, null, '2026-04-23T10:00:00.000Z', '2026-04-23T10:00:00.000Z', null, 'await-reply', 1, 0), + ('thr_archived', 'C13', '/tmp/project', 'Archived', null, null, '2026-04-23T11:00:00.000Z', '2026-04-23T11:00:00.000Z', null, 'await-reply', 1, 1)`, + ).run(); + db.query( + `insert into telegram_delivery_receipts ( + id, + notification_id, + thread_id, + bot_token, + chat_id, + telegram_message_id, + created_at + ) values + ('r_old', 'n1', 'thr_old', 'bot', 'chat', 10, '2026-04-23T09:00:00.000Z'), + ('r_new', 'n1', 'thr_new', 'bot', 'chat', 11, '2026-04-23T10:00:00.000Z'), + ('r_archived', 'n1', 'thr_archived', 'bot', 'chat', 12, '2026-04-23T11:00:00.000Z')`, + ).run(); + + expect(findLatestDeliveredTelegramSessionId(db, "bot", "chat")).toBe("thr_new"); + }); +}); + +describe("telegram bridge failsafe", () => { + test("disables one session and clears only that session's pending remote state", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + insertFailsafeFixtureSessions(db); + insertFailsafeFixtureRemoteState(db); + + disableTelegramSessionViaFailsafe(db, "thr_target"); + + expect( + db + .query("select preset, preset_overridden, active_since from sessions where thread_id = ?") + .get("thr_target"), + ).toEqual({ + preset: null, + preset_overridden: 1, + active_since: null, + }); + expect(db.query("select count(*) as count from session_runtime").get()).toEqual({ count: 1 }); + expect(db.query("select count(*) as count from session_awaiting_replies").get()).toEqual({ + count: 1, + }); + expect(db.query("select count(*) as count from session_remote_prompts").get()).toEqual({ + count: 1, + }); + expect( + db + .query("select prompt_text from session_remote_prompts where thread_id = ?") + .get("thr_other"), + ).toEqual({ prompt_text: "other prompt" }); + }); + + test("disables global and every active session while clearing pending remote state", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + db.query("update settings set global_preset = 'await-reply' where id = 1").run(); + insertFailsafeFixtureSessions(db); + insertFailsafeFixtureRemoteState(db); + + disableAllTelegramSessionsViaFailsafe(db); + + expect(db.query("select global_preset from settings where id = 1").get()).toEqual({ + global_preset: null, + }); + expect( + db + .query( + "select count(*) as count from sessions where preset is null and preset_overridden = 1 and active_since is null", + ) + .get(), + ).toEqual({ count: 2 }); + expect(db.query("select count(*) as count from session_runtime").get()).toEqual({ count: 0 }); + expect(db.query("select count(*) as count from session_awaiting_replies").get()).toEqual({ + count: 0, + }); + expect(db.query("select count(*) as count from session_remote_prompts").get()).toEqual({ + count: 0, + }); + }); +}); + +describe("telegram bridge status states", () => { + test("reports awaiting replies and queued Telegram prompts for status output", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + insertTelegramNotification(db); + insertFailsafeFixtureSessions(db); + insertFailsafeFixtureRemoteState(db); + attachTelegramNotification(db, ["thr_target", "thr_other"]); + db.query("update session_remote_prompts set telegram_chat_id = 'chat'").run(); + + const states = getTelegramSessionBridgeStates(db, "bot", "chat"); + + expect(states.awaitingReplySessionIds.has("thr_target")).toBe(true); + expect(states.awaitingReplySessionIds.has("thr_other")).toBe(true); + expect(states.queuedPromptSessionIds.has("thr_target")).toBe(true); + expect(states.queuedPromptSessionIds.has("thr_other")).toBe(true); + }); + + test("scopes queued Telegram prompt status to the active destination", () => { + const db = new Database(":memory:"); + createTelegramBridgeSchema(db); + insertTelegramNotification(db); + insertOtherTelegramNotification(db); + insertFailsafeFixtureSessions(db); + attachTelegramNotification(db, ["thr_target"]); + attachOtherTelegramNotification(db, ["thr_target"]); + db.query( + `insert into session_remote_prompts ( + thread_id, + source, + delivery_mode, + prompt_text, + telegram_chat_id, + telegram_message_id, + created_at + ) values ( + 'thr_target', + 'telegram', + 'once', + 'queued from first chat', + 'chat', + 10, + '2026-04-23T10:03:00.000Z' + )`, + ).run(); + + const firstChatStates = getTelegramSessionBridgeStates(db, "bot", "chat"); + const secondChatStates = getTelegramSessionBridgeStates(db, "bot", "other-chat"); + + expect(firstChatStates.queuedPromptSessionIds.has("thr_target")).toBe(true); + expect(secondChatStates.queuedPromptSessionIds.has("thr_target")).toBe(false); + }); +}); diff --git a/src/bun/telegram-bridge-session-store.ts b/src/bun/telegram-bridge-session-store.ts new file mode 100644 index 0000000..268b50a --- /dev/null +++ b/src/bun/telegram-bridge-session-store.ts @@ -0,0 +1,486 @@ +import { type Database } from "bun:sqlite"; +import type { LoopPreset, LoopSession } from "../shared/app-rpc"; +import { + isPersistentPromptPreset, + normalizeLoopPreset, + nowIsoString, + resolveSessionPresetState, +} from "./loopndroll-core"; +import { + buildTelegramPromptReceivedText, + getTelegramRemotePromptDeliveryMode, +} from "./telegram-control"; +import { looksInternalThreadNameArtifact } from "./thread-name-artifact"; +import type { TelegramInboundMessage } from "./telegram-utils"; + +export type TelegramBridgeTargetSession = { + sessionId: string; + sessionRef: string; + cwd?: string | null; + title: string | null; +}; + +export type TelegramSessionBridgeStates = { + awaitingReplySessionIds: Set; + queuedPromptSessionIds: Set; +}; + +export function getTelegramSessionBridgeStates( + db: Database, + botToken: string, + chatId: string, +): TelegramSessionBridgeStates { + const awaitingRows = db + .query( + `select ar.thread_id as session_id + from session_awaiting_replies ar + inner join sessions s on s.thread_id = ar.thread_id + where ar.bot_token = ? + and ar.chat_id = ? + and s.archived = 0`, + ) + .all(botToken, chatId) as Array<{ session_id?: string }>; + const queuedRows = db + .query( + `select distinct rp.thread_id as session_id + from session_remote_prompts rp + inner join sessions s on s.thread_id = rp.thread_id + inner join session_notifications sn on sn.thread_id = s.thread_id + inner join notifications n on n.id = sn.notification_id + where rp.source = 'telegram' + and rp.telegram_chat_id = ? + and n.channel = 'telegram' + and n.bot_token = ? + and n.chat_id = ? + and s.archived = 0`, + ) + .all(chatId, botToken, chatId) as Array<{ session_id?: string }>; + + return { + awaitingReplySessionIds: new Set( + awaitingRows.flatMap((row) => + typeof row.session_id === "string" && row.session_id.length > 0 ? [row.session_id] : [], + ), + ), + queuedPromptSessionIds: new Set( + queuedRows.flatMap((row) => + typeof row.session_id === "string" && row.session_id.length > 0 ? [row.session_id] : [], + ), + ), + }; +} + +export function listRegisteredTelegramSessions( + db: Database, + botToken: string, + chatId: string, +): LoopSession[] { + const settingsRow = db.query("select global_preset from settings where id = 1").get() as { + global_preset?: unknown; + } | null; + const globalPreset = normalizeLoopPreset(settingsRow?.global_preset); + const rows = db + .query( + `select distinct + s.thread_id as session_id, + s.session_ref, + s.thread_name as title, + s.cwd, + s.transcript_path, + s.last_assistant_message, + s.last_seen_at, + s.active_since, + s.preset, + s.preset_overridden + from sessions s + inner join session_notifications sn on sn.thread_id = s.thread_id + inner join notifications n on n.id = sn.notification_id + where n.channel = 'telegram' + and n.bot_token = ? + and n.chat_id = ? + order by s.last_seen_at desc, s.first_seen_at desc`, + ) + .all(botToken, chatId) as Array<{ + session_id: string; + session_ref: string; + title: string | null; + cwd: string | null; + transcript_path: string | null; + last_assistant_message: string | null; + last_seen_at: string; + active_since: string | null; + preset: LoopPreset | null; + preset_overridden: number | boolean | null; + }>; + + return rows + .map((row) => { + const presetState = resolveSessionPresetState( + row.preset, + row.preset_overridden, + globalPreset, + ); + + return { + threadId: row.session_id, + sessionId: row.session_id, + sessionRef: row.session_ref, + source: "stop" as const, + cwd: row.cwd, + notificationIds: [], + archived: false, + firstSeenAt: row.last_seen_at, + lastSeenAt: row.last_seen_at, + activeSince: row.active_since, + stopCount: 0, + preset: presetState.preset, + presetSource: presetState.presetSource, + effectivePreset: presetState.effectivePreset, + completionCheckId: null, + completionCheckWaitForReply: false, + effectiveCompletionCheckId: null, + effectiveCompletionCheckWaitForReply: false, + threadName: row.title, + title: row.title, + transcriptPath: row.transcript_path, + lastAssistantMessage: row.last_assistant_message, + } satisfies LoopSession; + }) + .filter((session) => isVisibleTelegramBridgeSession(session)); +} + +function isVisibleTelegramBridgeSession(session: LoopSession) { + return !( + looksInternalThreadNameArtifact(session.threadName) || + (session.notificationIds.length === 0 && + session.transcriptPath === null && + (session.lastAssistantMessage?.startsWith('{"title":') ?? false)) + ); +} + +export function getEffectivePresetForSession(db: Database, sessionId: string) { + const row = db + .query( + `select + s.preset as session_preset, + s.preset_overridden as preset_overridden, + s.archived as session_archived, + st.global_preset as global_preset + from sessions s + left join settings st on st.id = 1 + where s.thread_id = ? + limit 1`, + ) + .get(sessionId) as { + session_preset?: unknown; + preset_overridden?: unknown; + session_archived?: unknown; + global_preset?: unknown; + } | null; + + if (row?.session_archived) { + return null; + } + + return resolveSessionPresetState(row?.session_preset, row?.preset_overridden, row?.global_preset) + .effectivePreset; +} + +export function findTelegramReplySessionId( + db: Database, + botToken: string, + chatId: string, + replyToMessageId: number, +) { + const row = db + .query( + `select thread_id as session_id + from telegram_delivery_receipts + where bot_token = ? + and chat_id = ? + and telegram_message_id = ? + order by created_at desc + limit 1`, + ) + .get(botToken, chatId, replyToMessageId) as { session_id?: string } | null; + + return typeof row?.session_id === "string" && row.session_id.length > 0 ? row.session_id : null; +} + +export function findLatestAwaitingTelegramSessionId( + db: Database, + botToken: string, + chatId: string, +) { + const row = db + .query( + `select ar.thread_id as session_id + from session_awaiting_replies ar + inner join sessions s on s.thread_id = ar.thread_id + where ar.bot_token = ? + and ar.chat_id = ? + and s.archived = 0 + order by ar.started_at desc, ar.thread_id desc + limit 1`, + ) + .get(botToken, chatId) as { session_id?: string } | null; + + return typeof row?.session_id === "string" && row.session_id.length > 0 ? row.session_id : null; +} + +export function findLatestDeliveredTelegramSessionId( + db: Database, + botToken: string, + chatId: string, +) { + const row = db + .query( + `select r.thread_id as session_id + from telegram_delivery_receipts r + inner join sessions s on s.thread_id = r.thread_id + where r.bot_token = ? + and r.chat_id = ? + and s.archived = 0 + order by r.created_at desc, r.telegram_message_id desc + limit 1`, + ) + .get(botToken, chatId) as { session_id?: string } | null; + + return typeof row?.session_id === "string" && row.session_id.length > 0 ? row.session_id : null; +} + +export function findTelegramSessionByRef( + db: Database, + botToken: string, + chatId: string, + sessionRef: string, +): TelegramBridgeTargetSession | null { + const row = db + .query( + `select distinct + s.thread_id as session_id, + s.session_ref, + s.cwd, + s.thread_name as title + from sessions s + inner join session_notifications sn on sn.thread_id = s.thread_id + inner join notifications n on n.id = sn.notification_id + where n.channel = 'telegram' + and n.bot_token = ? + and n.chat_id = ? + and lower(s.session_ref) = lower(?) + limit 1`, + ) + .get(botToken, chatId, sessionRef) as { + session_id?: string; + session_ref?: string; + cwd?: string | null; + title?: string | null; + } | null; + + if (!row?.session_id || !row?.session_ref || looksInternalThreadNameArtifact(row.title)) { + return null; + } + + return { + sessionId: row.session_id, + sessionRef: row.session_ref, + cwd: row.cwd ?? null, + title: row.title ?? null, + }; +} + +export function findTelegramSessionById( + db: Database, + sessionId: string, +): TelegramBridgeTargetSession | null { + const row = db + .query( + `select + thread_id as session_id, + session_ref, + cwd, + thread_name as title + from sessions + where thread_id = ? + limit 1`, + ) + .get(sessionId) as { + session_id?: string; + session_ref?: string; + cwd?: string | null; + title?: string | null; + } | null; + + if (!row?.session_id || !row?.session_ref) { + return null; + } + + return { + sessionId: row.session_id, + sessionRef: row.session_ref, + cwd: row.cwd ?? null, + title: row.title ?? null, + }; +} + +export function clearRemotePromptStateForPreset( + db: Database, + sessionId: string, + preset: LoopPreset | null, +) { + if (!preset || !isPersistentPromptPreset(preset)) { + db.query("delete from session_runtime where thread_id = ?").run(sessionId); + + if (preset !== "await-reply") { + db.query("delete from session_awaiting_replies where thread_id = ?").run(sessionId); + } + if (preset === null) { + db.query("delete from session_remote_prompts where thread_id = ?").run(sessionId); + return; + } + if (preset === "await-reply") { + db.query( + "delete from session_remote_prompts where thread_id = ? and delivery_mode = 'persistent'", + ).run(sessionId); + return; + } + db.query("delete from session_remote_prompts where thread_id = ?").run(sessionId); + return; + } + + db.query( + "delete from session_remote_prompts where thread_id = ? and delivery_mode = 'persistent'", + ).run(sessionId); +} + +export function clearRemotePromptStateForGlobalPreset(db: Database, preset: LoopPreset | null) { + if (preset !== "await-reply") { + db.query( + `delete from session_awaiting_replies + where thread_id in ( + select thread_id + from sessions + where preset is null + and preset_overridden = 0 + and archived = 0 + )`, + ).run(); + } + if (preset === null) { + db.query( + `delete from session_remote_prompts + where thread_id in ( + select thread_id + from sessions + where preset is null + and preset_overridden = 0 + and archived = 0 + )`, + ).run(); + return; + } + if (!isPersistentPromptPreset(preset)) { + db.query( + `delete from session_remote_prompts + where delivery_mode = 'persistent' + and thread_id in ( + select thread_id + from sessions + where preset is null + and preset_overridden = 0 + and archived = 0 + )`, + ).run(); + } +} + +export function disableTelegramSessionViaFailsafe(db: Database, sessionId: string) { + const applyUpdate = db.transaction(() => { + db.query( + `update sessions + set preset = null, + preset_overridden = 1, + active_since = null + where thread_id = ? + and archived = 0`, + ).run(sessionId); + + db.query("delete from session_runtime where thread_id = ?").run(sessionId); + db.query("delete from session_awaiting_replies where thread_id = ?").run(sessionId); + db.query("delete from session_remote_prompts where thread_id = ?").run(sessionId); + }); + + applyUpdate(); +} + +export function disableAllTelegramSessionsViaFailsafe(db: Database) { + const applyUpdate = db.transaction(() => { + db.query("update settings set global_preset = null where id = 1").run(); + db.run( + `update sessions + set preset = null, + preset_overridden = 1, + active_since = null + where archived = 0`, + ); + db.query("delete from session_runtime").run(); + db.query("delete from session_awaiting_replies").run(); + db.query("delete from session_remote_prompts").run(); + }); + + applyUpdate(); +} + +export function upsertSessionRemotePrompt( + db: Database, + sessionId: string, + promptText: string, + deliveryMode: "once" | "persistent", + message: TelegramInboundMessage, +) { + const trimmedPrompt = promptText.trim(); + if (trimmedPrompt.length === 0) { + return false; + } + + db.query( + `insert into session_remote_prompts ( + thread_id, + source, + delivery_mode, + prompt_text, + telegram_chat_id, + telegram_message_id, + created_at + ) values (?, 'telegram', ?, ?, ?, ?, ?) + on conflict(thread_id, delivery_mode) do update set + source = excluded.source, + delivery_mode = excluded.delivery_mode, + prompt_text = excluded.prompt_text, + telegram_chat_id = excluded.telegram_chat_id, + telegram_message_id = excluded.telegram_message_id, + created_at = excluded.created_at`, + ).run( + sessionId, + deliveryMode, + trimmedPrompt, + typeof message.chat?.id === "number" || typeof message.chat?.id === "string" + ? String(message.chat.id) + : null, + typeof message.message_id === "number" ? message.message_id : null, + nowIsoString(), + ); + + return true; +} + +export function buildReplyQueuedAck( + targetSession: TelegramBridgeTargetSession, + effectivePreset: LoopPreset, +) { + return { + ackText: buildTelegramPromptReceivedText(targetSession), + deliveryMode: getTelegramRemotePromptDeliveryMode(effectivePreset), + }; +} diff --git a/src/bun/telegram-bridge-text.test.ts b/src/bun/telegram-bridge-text.test.ts new file mode 100644 index 0000000..f71029f --- /dev/null +++ b/src/bun/telegram-bridge-text.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildNoActiveModeForTargetText, + buildNoSafeActiveChannelText, + buildTelegramHelpText, + buildTelegramStatusText, +} from "./telegram-bridge-text"; + +describe("buildTelegramStatusText", () => { + test("shows runtime state and paused guidance", () => { + const text = buildTelegramStatusText( + { + scope: "global", + runtimeState: "paused", + globalPreset: "await-reply", + hooksAutoRegistration: true, + }, + [], + ); + + expect(text).toContain("System: paused"); + expect(text).toContain("Hooks auto-registration: On"); + expect(text).toContain("Global preset: Await Reply"); + expect(text).toContain("Remote control is paused."); + }); + + test("shows stopped guidance", () => { + const text = buildTelegramStatusText( + { + scope: "global", + runtimeState: "stopped", + globalPreset: null, + hooksAutoRegistration: false, + }, + [], + ); + + expect(text).toContain("System: stopped"); + expect(text).toContain("Hooks auto-registration: Off"); + expect(text).toContain("Loopndroll is stopped."); + }); +}); + +describe("buildTelegramStatusText per-chat bridge states", () => { + test("shows per-chat waiting and queued Telegram state", () => { + const text = buildTelegramStatusText( + { + scope: "global", + runtimeState: "running", + globalPreset: "await-reply", + hooksAutoRegistration: true, + }, + [ + { + threadId: "thr_waiting", + sessionId: "thr_waiting", + sessionRef: "C1", + source: "startup", + cwd: "/tmp/project", + notificationIds: [], + archived: false, + firstSeenAt: "2026-04-28T00:00:00.000Z", + lastSeenAt: "2026-04-28T00:00:00.000Z", + activeSince: null, + stopCount: 0, + preset: "await-reply", + presetSource: "session", + effectivePreset: "await-reply", + completionCheckId: null, + completionCheckWaitForReply: false, + effectiveCompletionCheckId: null, + effectiveCompletionCheckWaitForReply: false, + threadName: "Waiting chat", + title: "Waiting chat", + transcriptPath: null, + lastAssistantMessage: null, + }, + { + threadId: "thr_queued", + sessionId: "thr_queued", + sessionRef: "C2", + source: "startup", + cwd: "/tmp/project", + notificationIds: [], + archived: false, + firstSeenAt: "2026-04-28T00:00:00.000Z", + lastSeenAt: "2026-04-28T00:00:00.000Z", + activeSince: null, + stopCount: 0, + preset: "infinite", + presetSource: "session", + effectivePreset: "infinite", + completionCheckId: null, + completionCheckWaitForReply: false, + effectiveCompletionCheckId: null, + effectiveCompletionCheckWaitForReply: false, + threadName: "Queued chat", + title: "Queued chat", + transcriptPath: null, + lastAssistantMessage: null, + }, + ], + { + awaitingReplySessionIds: new Set(["thr_waiting"]), + queuedPromptSessionIds: new Set(["thr_queued"]), + }, + ); + + expect(text).toContain("[project] [C1] Waiting chat: Await Reply - awaiting Telegram reply"); + expect(text).toContain("[project] [C2] Queued chat: Infinite - queued Telegram prompt"); + }); +}); + +describe("buildTelegramHelpText", () => { + test("describes reply behavior and v1 modes clearly", () => { + const text = buildTelegramHelpText(); + + expect(text).toContain("/reply C22 your message - Fallback: send a prompt to a specific chat"); + expect(text).toContain("Use /reply only as a fallback"); + expect(text).toContain( + "Plain text targets the latest safe Telegram-linked chat when it has an active mode.", + ); + expect(text).toContain("If that chat is Off, Loopndroll reports that nothing was delivered."); + expect(text).toContain("Await Reply: sends a notification and keeps Codex waiting"); + expect(text).toContain("does not wake Codex in v1"); + expect(text).not.toContain("Passive:"); + }); +}); + +describe("buildNoSafeActiveChannelText", () => { + test("states that v1 will not wake Codex without a hook-backed waiting chat", () => { + const text = buildNoSafeActiveChannelText(); + + expect(text).toContain("Reply not delivered: no safe active channel"); + expect(text).toContain("hook-backed chat is active"); + expect(text).toContain("/reply C2 your message"); + }); +}); + +describe("buildNoActiveModeForTargetText", () => { + test("states that a loose message found a target but cannot deliver while off", () => { + const text = buildNoActiveModeForTargetText({ + cwd: "/Users/example/Documents/loopndroll-threadmark", + sessionRef: "C2", + title: "Verificar loopndroll seguro", + }); + + expect(text).toContain("Reply not delivered: chat is Off"); + expect(text).toContain("[loopndroll-threadmark] [C2]"); + expect(text).toContain("Thread: Verificar loopndroll seguro"); + expect(text).toContain("latest Telegram-linked chat"); + expect(text).toContain("/mode C2 await"); + }); +}); diff --git a/src/bun/telegram-bridge-text.ts b/src/bun/telegram-bridge-text.ts index 17fa8c2..10aec27 100644 --- a/src/bun/telegram-bridge-text.ts +++ b/src/bun/telegram-bridge-text.ts @@ -1,6 +1,11 @@ import { type Database } from "bun:sqlite"; -import type { LoopPreset, LoopScope, LoopSession } from "../shared/app-rpc"; -import { normalizeLoopPreset, normalizeScope } from "./loopndroll-core"; +import type { LoopPreset, LoopScope, LoopSession, LoopndrollRuntimeState } from "../shared/app-rpc"; +import { + normalizeLoopPreset, + normalizeLoopndrollRuntimeState, + normalizeScope, +} from "./loopndroll-core"; +import { formatTelegramSessionLabel } from "./telegram-output"; export function buildTelegramSessionListText(sessionsForChat: LoopSession[]) { if (sessionsForChat.length === 0) { @@ -8,11 +13,15 @@ export function buildTelegramSessionListText(sessionsForChat: LoopSession[]) { } const lines = sessionsForChat.slice(0, 20).map((session) => { - const title = - typeof session.title === "string" && session.title.trim().length > 0 - ? session.title.trim() - : "Untitled chat"; - return `[${session.sessionRef}] - ${title}`; + const threadName = + typeof session.threadName === "string" && session.threadName.trim().length > 0 + ? session.threadName.trim() + : null; + return formatTelegramSessionLabel({ + cwd: session.cwd, + sessionRef: session.sessionRef, + title: threadName, + }); }); const suffix = @@ -23,6 +32,14 @@ export function buildTelegramSessionListText(sessionsForChat: LoopSession[]) { return `Registered chats:\n${lines.join("\n")}${suffix}`; } +function formatTelegramStatusSessionLabel(input: { + cwd?: string | null; + sessionRef?: string | null; + title?: string | null; +}) { + return formatTelegramSessionLabel(input).replace("\nThread: ", " "); +} + function getLoopPresetLabel(preset: LoopPreset | null) { if (preset === "infinite") { return "Infinite"; @@ -47,15 +64,19 @@ function getLoopPresetLabel(preset: LoopPreset | null) { export function getTelegramStatusSnapshot(db: Database) { const row = db - .query("select scope, global_preset, hooks_auto_registration from settings where id = 1") + .query( + "select scope, runtime_state, global_preset, hooks_auto_registration from settings where id = 1", + ) .get() as { scope?: unknown; + runtime_state?: unknown; global_preset?: unknown; hooks_auto_registration?: number | boolean; } | null; return { scope: normalizeScope(row?.scope), + runtimeState: normalizeLoopndrollRuntimeState(row?.runtime_state), globalPreset: normalizeLoopPreset(row?.global_preset), hooksAutoRegistration: typeof row?.hooks_auto_registration === "boolean" @@ -67,17 +88,30 @@ export function getTelegramStatusSnapshot(db: Database) { export function buildTelegramStatusText( settingsSnapshot: { scope: LoopScope; + runtimeState: LoopndrollRuntimeState; globalPreset: LoopPreset | null; hooksAutoRegistration: boolean; }, sessionsForChat: LoopSession[], + bridgeStates: { + awaitingReplySessionIds?: ReadonlySet; + queuedPromptSessionIds?: ReadonlySet; + } = {}, ) { const visibleSessions = sessionsForChat.filter((session) => !session.archived); const lines = [ "Current status:", + `System: ${settingsSnapshot.runtimeState}`, + `Hooks auto-registration: ${settingsSnapshot.hooksAutoRegistration ? "On" : "Off"}`, `Global preset: ${getLoopPresetLabel(settingsSnapshot.globalPreset)}`, ]; + if (settingsSnapshot.runtimeState === "paused") { + lines.push("Remote control is paused. Resume from the app before sending new prompts."); + } else if (settingsSnapshot.runtimeState === "stopped") { + lines.push("Loopndroll is stopped. Start it from the app before sending new prompts."); + } + if (visibleSessions.length === 0) { lines.push("", "Registered chats: none"); return lines.join("\n"); @@ -85,17 +119,30 @@ export function buildTelegramStatusText( lines.push("", "Per-chat presets:"); for (const session of visibleSessions.slice(0, 20)) { - const title = - typeof session.title === "string" && session.title.trim().length > 0 - ? session.title.trim() - : "Untitled chat"; + const threadName = + typeof session.threadName === "string" && session.threadName.trim().length > 0 + ? session.threadName.trim() + : null; const presetLabel = session.presetSource === "session" ? getLoopPresetLabel(session.preset) : session.presetSource === "off" ? "Off" : "Inherit global"; - lines.push(`[${session.sessionRef}] - ${title}: ${presetLabel}`); + const bridgeState = bridgeStates.awaitingReplySessionIds?.has(session.sessionId) + ? "awaiting Telegram reply" + : bridgeStates.queuedPromptSessionIds?.has(session.sessionId) + ? "queued Telegram prompt" + : session.effectivePreset === "await-reply" + ? "waiting for next Codex stop" + : "no Telegram input waiting"; + lines.push( + `${formatTelegramStatusSessionLabel({ + cwd: session.cwd, + sessionRef: session.sessionRef, + title: threadName, + })}: ${presetLabel} - ${bridgeState}`, + ); } if (visibleSessions.length > 20) { @@ -109,8 +156,8 @@ export function buildTelegramHelpText() { return [ "Available commands:", "/list - List chats registered to this Telegram destination", - "/status - Show current global mode and per-chat presets", - "/reply C22 your message - Send a prompt to a specific chat", + "/status - Show the system state, global preset, and per-chat presets", + "/reply C22 your message - Fallback: send a prompt to a specific chat", "/mode global infinite - Set the global preset to Infinite", "/mode global await - Set the global preset to Await Reply", "/mode global checks - Set the global preset to Completion checks", @@ -118,20 +165,58 @@ export function buildTelegramHelpText() { "/mode C22 infinite - Set chat C22 to Infinite", "/mode C22 await - Set chat C22 to Await Reply", "/mode C22 off - Stop chat C22", + "/failsafe C22 - Immediately disable control for chat C22 and clear its pending prompts", + "/failsafe all - Immediately disable the global mode, every chat mode, and all pending prompts", "", "Reply behavior:", "Reply directly to a Telegram notification to target that chat.", - "Or send plain text without a command to target the latest waiting chat in this Telegram conversation.", + "Use /reply only as a fallback when you do not want to reply to the Telegram message directly.", + "Plain text targets the latest safe Telegram-linked chat when it has an active mode.", + "If that chat is Off, Loopndroll reports that nothing was delivered.", + "Loopndroll does not wake Codex in v1.", + "", + "Modes:", + "Await Reply: sends a notification and keeps Codex waiting for your reply.", + "Infinite: keeps sending a persistent prompt until you change the mode.", + "Off: disables the preset for that chat or global default.", "", "Examples:", "/list", "/status", "/reply C22 fix the failing test", "/mode global await", + "/mode C22 await", + "/failsafe C22", + "/failsafe all", "/mode C22 off", ].join("\n"); } +export function buildNoSafeActiveChannelText() { + return [ + "Reply not delivered: no safe active channel", + "", + "Loopndroll v1 only accepts Telegram input when a hook-backed chat is active.", + "Reply to a Loopndroll notification, use /reply C2 your message, or run /status.", + ].join("\n"); +} + +export function buildNoActiveModeForTargetText(input: { + cwd?: string | null; + sessionRef?: string | null; + title?: string | null; +}) { + return [ + "Reply not delivered: chat is Off", + formatTelegramSessionLabel(input), + "", + "---------", + "", + "Loopndroll found this as the latest Telegram-linked chat, but it is Off.", + `Use /mode ${input.sessionRef ?? "C22"} await, then wait for the next Codex stop.`, + ].join("\n"); +} + export function getModeCommandLabel(preset: LoopPreset | null) { if (preset === "infinite") { return "Infinite"; diff --git a/src/bun/telegram-bridge.ts b/src/bun/telegram-bridge.ts index 51c4201..d5dffc7 100644 --- a/src/bun/telegram-bridge.ts +++ b/src/bun/telegram-bridge.ts @@ -5,9 +5,7 @@ import { TELEGRAM_BRIDGE_POLL_INTERVAL_MS, type LoopndrollPaths, appendHookDebugLog, - isPersistentPromptPreset, - isPromptOnlyArtifact, - normalizeLoopPreset, + normalizeLoopndrollRuntimeState, nowIsoString, optOutExistingInactiveSessionsFromGlobalPreset, resolveSessionPresetState, @@ -15,18 +13,43 @@ import { } from "./loopndroll-core"; import { buildTelegramHelpText, + buildNoActiveModeForTargetText, + buildNoSafeActiveChannelText, buildTelegramSessionListText, buildTelegramStatusText, getModeCommandLabel, getTelegramStatusSnapshot, } from "./telegram-bridge-text"; import { - collectTelegramChatsFromUpdates, + buildTelegramPromptReceivedText, + getTelegramRemotePromptDeliveryMode, +} from "./telegram-control"; +import { + createTelegramBridgeUpdateContext, + formatTelegramTargetSessionLabel, + prepareTelegramBridgeUpdate, + type TelegramBridgeUpdateContext, +} from "./telegram-bridge-context"; +import { + clearRemotePromptStateForGlobalPreset, + clearRemotePromptStateForPreset, + disableAllTelegramSessionsViaFailsafe, + disableTelegramSessionViaFailsafe, + findLatestAwaitingTelegramSessionId, + findLatestDeliveredTelegramSessionId, + findTelegramReplySessionId, + findTelegramSessionById, + findTelegramSessionByRef, + getEffectivePresetForSession, + getTelegramSessionBridgeStates, + listRegisteredTelegramSessions, + type TelegramBridgeTargetSession, + upsertSessionRemotePrompt, +} from "./telegram-bridge-session-store"; +import { fetchTelegramUpdates, sendTelegramBridgeMessage, - type TelegramInboundMessage, type TelegramUpdate, - upsertKnownTelegramChats, } from "./telegram-utils"; function getTelegramBridgeBotTokens(db: Database) { @@ -64,6 +87,12 @@ function setTelegramUpdateCursor(db: Database, botToken: string, lastUpdateId: n ).run(botToken, lastUpdateId, updatedAt); } +type TelegramBridgePollDependencies = { + fetchUpdates?: typeof fetchTelegramUpdates; + processUpdate?: typeof processTelegramBridgeUpdate; + log?: typeof appendHookDebugLog; +}; + function isAuthorizedTelegramBridgeChat(db: Database, botToken: string, chatId: string) { const row = db .query( @@ -85,179 +114,29 @@ function getTelegramCommandName(text: string) { return match?.[1]?.toLowerCase() ?? null; } -function listRegisteredTelegramSessions(db: Database, botToken: string, chatId: string) { - const settingsRow = db.query("select global_preset from settings where id = 1").get() as { - global_preset?: unknown; - } | null; - const globalPreset = normalizeLoopPreset(settingsRow?.global_preset); - const rows = db - .query( - `select distinct - s.session_id, - s.session_ref, - s.title, - s.transcript_path, - s.last_assistant_message, - s.last_seen_at, - s.active_since, - s.preset, - s.preset_overridden - from sessions s - inner join session_notifications sn on sn.session_id = s.session_id - inner join notifications n on n.id = sn.notification_id - where n.channel = 'telegram' - and n.bot_token = ? - and n.chat_id = ? - order by s.last_seen_at desc, s.first_seen_at desc`, - ) - .all(botToken, chatId) as Array<{ - session_id: string; - session_ref: string; - title: string | null; - transcript_path: string | null; - last_assistant_message: string | null; - last_seen_at: string; - active_since: string | null; - preset: LoopPreset | null; - preset_overridden: number | boolean | null; - }>; - - return rows - .map((row) => { - const presetState = resolveSessionPresetState( - row.preset, - row.preset_overridden, - globalPreset, - ); - - return { - sessionId: row.session_id, - sessionRef: row.session_ref, - source: "stop" as const, - cwd: null, - notificationIds: [], - archived: false, - firstSeenAt: row.last_seen_at, - lastSeenAt: row.last_seen_at, - activeSince: row.active_since, - stopCount: 0, - preset: presetState.preset, - presetSource: presetState.presetSource, - effectivePreset: presetState.effectivePreset, - completionCheckId: null, - completionCheckWaitForReply: false, - effectiveCompletionCheckId: null, - effectiveCompletionCheckWaitForReply: false, - title: row.title, - transcriptPath: row.transcript_path, - lastAssistantMessage: row.last_assistant_message, - }; - }) - .filter((session) => !isPromptOnlyArtifact(session)); -} - -function getEffectivePresetForSession(db: Database, sessionId: string) { - const row = db - .query( - `select - s.preset as session_preset, - s.preset_overridden as preset_overridden, - s.archived as session_archived, - st.global_preset as global_preset - from sessions s - left join settings st on st.id = 1 - where s.session_id = ? - limit 1`, - ) - .get(sessionId) as { - session_preset?: unknown; - preset_overridden?: unknown; - session_archived?: unknown; - global_preset?: unknown; +function getLoopndrollRuntimeState(db: Database) { + const row = db.query("select runtime_state from settings where id = 1").get() as { + runtime_state?: unknown; } | null; - if (row?.session_archived) { - return null; - } - - return resolveSessionPresetState(row?.session_preset, row?.preset_overridden, row?.global_preset) - .effectivePreset; + return normalizeLoopndrollRuntimeState(row?.runtime_state); } -function findTelegramReplySessionId( - db: Database, - botToken: string, - chatId: string, - replyToMessageId: number, +export function isTelegramCommandAllowedInRuntimeState( + runtimeState: ReturnType, + commandName: string | null, ) { - const row = db - .query( - `select session_id - from telegram_delivery_receipts - where bot_token = ? - and chat_id = ? - and telegram_message_id = ? - order by created_at desc - limit 1`, - ) - .get(botToken, chatId, replyToMessageId) as { session_id?: string } | null; - - return typeof row?.session_id === "string" && row.session_id.length > 0 ? row.session_id : null; -} - -function findLatestAwaitingTelegramSessionId(db: Database, botToken: string, chatId: string) { - const row = db - .query( - `select ar.session_id - from session_awaiting_replies ar - inner join sessions s on s.session_id = ar.session_id - where ar.bot_token = ? - and ar.chat_id = ? - and s.archived = 0 - order by ar.started_at desc, ar.session_id desc - limit 1`, - ) - .get(botToken, chatId) as { session_id?: string } | null; - - return typeof row?.session_id === "string" && row.session_id.length > 0 ? row.session_id : null; -} - -function findTelegramSessionByRef( - db: Database, - botToken: string, - chatId: string, - sessionRef: string, -) { - const row = db - .query( - `select distinct - s.session_id, - s.session_ref, - s.title - from sessions s - inner join session_notifications sn on sn.session_id = s.session_id - inner join notifications n on n.id = sn.notification_id - where n.channel = 'telegram' - and n.bot_token = ? - and n.chat_id = ? - and lower(s.session_ref) = lower(?) - limit 1`, - ) - .get(botToken, chatId, sessionRef) as { - session_id?: string; - session_ref?: string; - title?: string | null; - } | null; - - if (!row?.session_id || !row?.session_ref) { - return null; + if (runtimeState === "running") { + return true; } - return { - sessionId: row.session_id, - sessionRef: row.session_ref, - title: row.title ?? null, - }; + return ( + commandName === "status" || + commandName === "help" || + commandName === "list" || + commandName === "mode" || + commandName === "failsafe" + ); } function parseReplyCommand(text: string) { @@ -308,10 +187,21 @@ function parseModeCommand(text: string) { }; } +function parseFailsafeCommand(text: string) { + const match = /^\/failsafe(?:@\w+)?\s+(\S+)$/i.exec(text.trim()); + const target = match?.[1]?.trim() ?? ""; + if (target.length === 0) { + return null; + } + return target.toLowerCase() === "all" + ? { target: "all" as const } + : { target: "session" as const, sessionRef: target.toUpperCase() }; +} + function updateSessionPresetFromBridge(db: Database, sessionId: string, preset: LoopPreset | null) { const existingSession = db .query( - "select preset, preset_overridden, active_since, archived from sessions where session_id = ? limit 1", + "select preset, preset_overridden, active_since, archived from sessions where thread_id = ? limit 1", ) .get(sessionId) as { preset?: unknown; @@ -345,30 +235,26 @@ function updateSessionPresetFromBridge(db: Database, sessionId: string, preset: set preset = ?, preset_overridden = 1, active_since = ? - where session_id = ?`, + where thread_id = ?`, ).run(preset, nextActiveSince, sessionId); - db.query("delete from session_runtime where session_id = ?").run(sessionId); + db.query("delete from session_runtime where thread_id = ?").run(sessionId); if (preset !== "await-reply") { - db.query("delete from session_awaiting_replies where session_id = ?").run(sessionId); + db.query("delete from session_awaiting_replies where thread_id = ?").run(sessionId); } if (isRestartingFromOff) { - db.query("delete from session_remote_prompts where session_id = ?").run(sessionId); + clearRemotePromptStateForPreset(db, sessionId, null); return; } if (preset === null) { - db.query("delete from session_remote_prompts where session_id = ?").run(sessionId); + clearRemotePromptStateForPreset(db, sessionId, null); return; } - if (!isPersistentPromptPreset(preset)) { - db.query( - "delete from session_remote_prompts where session_id = ? and delivery_mode = 'persistent'", - ).run(sessionId); - } + clearRemotePromptStateForPreset(db, sessionId, preset); }); applyUpdate(); @@ -395,160 +281,12 @@ function updateGlobalPresetFromBridge(db: Database, preset: LoopPreset | null) { db.query("delete from session_runtime").run(); - if (preset !== "await-reply") { - db.run( - `delete from session_awaiting_replies - where session_id in ( - select session_id - from sessions - where preset is null - and preset_overridden = 0 - and archived = 0 - )`, - ); - } - - if (preset === null) { - db.run( - `delete from session_remote_prompts - where session_id in ( - select session_id - from sessions - where preset is null - and preset_overridden = 0 - and archived = 0 - )`, - ); - return; - } - - if (!isPersistentPromptPreset(preset)) { - db.run( - `delete from session_remote_prompts - where delivery_mode = 'persistent' - and session_id in ( - select session_id - from sessions - where preset is null - and preset_overridden = 0 - and archived = 0 - )`, - ); - } + clearRemotePromptStateForGlobalPreset(db, preset); }); applyUpdate(); } -function upsertSessionRemotePrompt( - db: Database, - sessionId: string, - promptText: string, - deliveryMode: "once" | "persistent", - message: TelegramInboundMessage, -) { - const trimmedPrompt = promptText.trim(); - if (trimmedPrompt.length === 0) { - return false; - } - - db.query( - `insert into session_remote_prompts ( - session_id, - source, - delivery_mode, - prompt_text, - telegram_chat_id, - telegram_message_id, - created_at - ) values (?, 'telegram', ?, ?, ?, ?, ?) - on conflict(session_id, delivery_mode) do update set - source = excluded.source, - delivery_mode = excluded.delivery_mode, - prompt_text = excluded.prompt_text, - telegram_chat_id = excluded.telegram_chat_id, - telegram_message_id = excluded.telegram_message_id, - created_at = excluded.created_at`, - ).run( - sessionId, - deliveryMode, - trimmedPrompt, - typeof message.chat?.id === "number" || typeof message.chat?.id === "string" - ? String(message.chat.id) - : null, - typeof message.message_id === "number" ? message.message_id : null, - nowIsoString(), - ); - - return true; -} - - -type TelegramBridgeUpdateContext = { - paths: LoopndrollPaths; - db: Database; - botToken: string; - update: TelegramUpdate; - message: TelegramInboundMessage; - trimmedText: string; - chatId: string; -}; - -function createTelegramBridgeUpdateContext( - paths: LoopndrollPaths, - db: Database, - botToken: string, - update: TelegramUpdate, -): TelegramBridgeUpdateContext | null { - const message = update.message; - if (!message || typeof message.text !== "string") { - return null; - } - - const trimmedText = message.text.trim(); - if (trimmedText.length === 0) { - return null; - } - - const chatId = - typeof message.chat?.id === "number" || typeof message.chat?.id === "string" - ? String(message.chat.id) - : null; - if (!chatId) { - return null; - } - - return { paths, db, botToken, update, message, trimmedText, chatId }; -} - -async function prepareTelegramBridgeUpdate(context: TelegramBridgeUpdateContext) { - if (!isAuthorizedTelegramBridgeChat(context.db, context.botToken, context.chatId)) { - await appendHookDebugLog(context.paths, { - type: "telegram-bridge", - action: "ignored-message", - reason: "unauthorized-chat", - botToken: context.botToken, - updateId: context.update.update_id ?? null, - chatId: context.chatId, - }); - return false; - } - - const discoveredChats = collectTelegramChatsFromUpdates([context.update]); - if (discoveredChats.length > 0) { - upsertKnownTelegramChats(context.db, context.botToken, discoveredChats); - } - - return true; -} - -function formatTelegramSessionLabel(targetSession: { - sessionRef: string; - title: string | null; -}) { - return `[${targetSession.sessionRef}]${targetSession.title ? ` - ${targetSession.title}` : ""}`; -} - async function handleListCommand(context: TelegramBridgeUpdateContext) { const sessionsForChat = listRegisteredTelegramSessions( context.db, @@ -577,10 +315,11 @@ async function handleStatusCommand(context: TelegramBridgeUpdateContext) { context.chatId, ); const settingsSnapshot = getTelegramStatusSnapshot(context.db); + const bridgeStates = getTelegramSessionBridgeStates(context.db, context.botToken, context.chatId); await sendTelegramBridgeMessage( context.botToken, context.chatId, - buildTelegramStatusText(settingsSnapshot, sessionsForChat), + buildTelegramStatusText(settingsSnapshot, sessionsForChat, bridgeStates), ); await appendHookDebugLog(context.paths, { type: "telegram-bridge", @@ -605,17 +344,64 @@ async function handleHelpCommand(context: TelegramBridgeUpdateContext) { }); } +async function sendReplyUsage(context: TelegramBridgeUpdateContext) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + "Usage: /reply C12 your message", + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "reply-usage", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + }); +} + +async function sendReplyMiss( + context: TelegramBridgeUpdateContext, + parsedReply: { sessionRef: string }, +) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + `Chat ${parsedReply.sessionRef} is not registered to this Telegram destination.`, + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "reply-miss", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + sessionRef: parsedReply.sessionRef, + }); +} + +async function sendReplyNoMode( + context: TelegramBridgeUpdateContext, + targetSession: TelegramBridgeTargetSession, +) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + `[${targetSession.sessionRef}] has no active mode. Use /mode ${targetSession.sessionRef} infinite|await|checks first.`, + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "reply-no-mode", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + sessionId: targetSession.sessionId, + sessionRef: targetSession.sessionRef, + }); +} + async function handleReplyCommand(context: TelegramBridgeUpdateContext) { const parsedReply = parseReplyCommand(context.trimmedText); if (!parsedReply) { - await sendTelegramBridgeMessage(context.botToken, context.chatId, "Usage: /reply C12 your message"); - await appendHookDebugLog(context.paths, { - type: "telegram-bridge", - action: "reply-usage", - botToken: context.botToken, - updateId: context.update.update_id ?? null, - chatId: context.chatId, - }); + await sendReplyUsage(context); return; } @@ -626,38 +412,13 @@ async function handleReplyCommand(context: TelegramBridgeUpdateContext) { parsedReply.sessionRef, ); if (!targetSession) { - await sendTelegramBridgeMessage( - context.botToken, - context.chatId, - `Chat ${parsedReply.sessionRef} is not registered to this Telegram destination.`, - ); - await appendHookDebugLog(context.paths, { - type: "telegram-bridge", - action: "reply-miss", - botToken: context.botToken, - updateId: context.update.update_id ?? null, - chatId: context.chatId, - sessionRef: parsedReply.sessionRef, - }); + await sendReplyMiss(context, parsedReply); return; } const effectivePreset = getEffectivePresetForSession(context.db, targetSession.sessionId); if (!effectivePreset) { - await sendTelegramBridgeMessage( - context.botToken, - context.chatId, - `[${targetSession.sessionRef}] has no active mode. Use /mode ${targetSession.sessionRef} infinite|await first.`, - ); - await appendHookDebugLog(context.paths, { - type: "telegram-bridge", - action: "reply-no-mode", - botToken: context.botToken, - updateId: context.update.update_id ?? null, - chatId: context.chatId, - sessionId: targetSession.sessionId, - sessionRef: targetSession.sessionRef, - }); + await sendReplyNoMode(context, targetSession); return; } @@ -665,15 +426,13 @@ async function handleReplyCommand(context: TelegramBridgeUpdateContext) { context.db, targetSession.sessionId, parsedReply.promptText, - effectivePreset === "await-reply" ? "once" : "persistent", + getTelegramRemotePromptDeliveryMode(effectivePreset), context.message, ); await sendTelegramBridgeMessage( context.botToken, context.chatId, - effectivePreset === "await-reply" - ? `Queued for ${formatTelegramSessionLabel(targetSession)}.` - : `Prompt override set for ${formatTelegramSessionLabel(targetSession)}.`, + buildTelegramPromptReceivedText(targetSession), ); await appendHookDebugLog(context.paths, { type: "telegram-bridge", @@ -692,7 +451,7 @@ async function handleModeCommand(context: TelegramBridgeUpdateContext) { await sendTelegramBridgeMessage( context.botToken, context.chatId, - "Usage: /mode global infinite|await|off or /mode C22 infinite|await|off", + "Usage: /mode global infinite|await|checks|off or /mode C22 infinite|await|checks|off", ); await appendHookDebugLog(context.paths, { type: "telegram-bridge", @@ -749,7 +508,7 @@ async function handleModeCommand(context: TelegramBridgeUpdateContext) { await sendTelegramBridgeMessage( context.botToken, context.chatId, - `${formatTelegramSessionLabel(targetSession)} set to ${getModeCommandLabel(parsedMode.preset)}.`, + `${formatTelegramTargetSessionLabel(targetSession)} set to ${getModeCommandLabel(parsedMode.preset)}.`, ); await appendHookDebugLog(context.paths, { type: "telegram-bridge", @@ -763,6 +522,81 @@ async function handleModeCommand(context: TelegramBridgeUpdateContext) { }); } +async function handleFailsafeCommand(context: TelegramBridgeUpdateContext) { + const parsedFailsafe = parseFailsafeCommand(context.trimmedText); + if (!parsedFailsafe) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + "Usage: /failsafe C22 or /failsafe all", + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "failsafe-usage", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + }); + return; + } + + if (parsedFailsafe.target === "all") { + disableAllTelegramSessionsViaFailsafe(context.db); + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + "Failsafe all applied. Global mode, per-chat modes, and pending remote prompts were disabled.", + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "failsafe-all", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + }); + return; + } + + const targetSession = findTelegramSessionByRef( + context.db, + context.botToken, + context.chatId, + parsedFailsafe.sessionRef, + ); + if (!targetSession) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + `Chat ${parsedFailsafe.sessionRef} is not registered to this Telegram destination.`, + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "failsafe-miss", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + sessionRef: parsedFailsafe.sessionRef, + }); + return; + } + + disableTelegramSessionViaFailsafe(context.db, targetSession.sessionId); + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + `${formatTelegramTargetSessionLabel(targetSession)} control disabled. Pending remote prompts for this chat were cleared.`, + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "failsafe-session", + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + sessionId: targetSession.sessionId, + sessionRef: targetSession.sessionRef, + }); +} + async function handleTelegramBridgeCommand( context: TelegramBridgeUpdateContext, commandName: string, @@ -788,6 +622,10 @@ async function handleTelegramBridgeCommand( await handleModeCommand(context); return true; } + case "failsafe": { + await handleFailsafeCommand(context); + return true; + } default: { return false; } @@ -799,12 +637,21 @@ async function handleFreeformTelegramMessage(context: TelegramBridgeUpdateContex const sessionId = typeof replyToMessageId === "number" ? findTelegramReplySessionId(context.db, context.botToken, context.chatId, replyToMessageId) - : findLatestAwaitingTelegramSessionId(context.db, context.botToken, context.chatId); + : (findLatestAwaitingTelegramSessionId(context.db, context.botToken, context.chatId) ?? + findLatestDeliveredTelegramSessionId(context.db, context.botToken, context.chatId)); if (!sessionId) { + if (typeof replyToMessageId !== "number") { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + buildNoSafeActiveChannelText(), + ); + } await appendHookDebugLog(context.paths, { type: "telegram-bridge", action: "ignored-message", - reason: typeof replyToMessageId === "number" ? "unknown-reply-target" : "no-waiting-session", + reason: + typeof replyToMessageId === "number" ? "unknown-reply-target" : "no-safe-active-channel", botToken: context.botToken, updateId: context.update.update_id ?? null, chatId: context.chatId, @@ -815,6 +662,14 @@ async function handleFreeformTelegramMessage(context: TelegramBridgeUpdateContex const effectivePreset = getEffectivePresetForSession(context.db, sessionId); if (!effectivePreset) { + const targetSession = findTelegramSessionById(context.db, sessionId); + if (targetSession) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + buildNoActiveModeForTargetText(targetSession), + ); + } await appendHookDebugLog(context.paths, { type: "telegram-bridge", action: "ignored-message", @@ -828,13 +683,21 @@ async function handleFreeformTelegramMessage(context: TelegramBridgeUpdateContex return; } + const targetSession = findTelegramSessionById(context.db, sessionId); const stored = upsertSessionRemotePrompt( context.db, sessionId, context.trimmedText, - effectivePreset === "await-reply" ? "once" : "persistent", + getTelegramRemotePromptDeliveryMode(effectivePreset), context.message, ); + if (stored && targetSession) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + buildTelegramPromptReceivedText(targetSession), + ); + } await appendHookDebugLog(context.paths, { type: "telegram-bridge", action: stored ? "queue-prompt" : "ignored-message", @@ -854,11 +717,30 @@ async function processTelegramBridgeUpdate( update: TelegramUpdate, ) { const context = createTelegramBridgeUpdateContext(paths, db, botToken, update); - if (!context || !(await prepareTelegramBridgeUpdate(context))) { + if (!context || !(await prepareTelegramBridgeUpdate(context, isAuthorizedTelegramBridgeChat))) { return; } + const runtimeState = getLoopndrollRuntimeState(db); const commandName = getTelegramCommandName(context.trimmedText); + if (!isTelegramCommandAllowedInRuntimeState(runtimeState, commandName)) { + await sendTelegramBridgeMessage( + context.botToken, + context.chatId, + `Loopndroll is ${runtimeState}. Use the app to ${runtimeState === "paused" ? "resume" : "start"} it first.`, + ); + await appendHookDebugLog(context.paths, { + type: "telegram-bridge", + action: "ignored-message", + reason: `runtime-${runtimeState}`, + botToken: context.botToken, + updateId: context.update.update_id ?? null, + chatId: context.chatId, + commandName, + }); + return; + } + if (commandName && (await handleTelegramBridgeCommand(context, commandName))) { return; } @@ -880,32 +762,76 @@ async function processTelegramBridgeUpdate( let telegramBridgeStarted = false; let telegramBridgePolling = false; -async function pollTelegramReplies() { - const paths = getLoopndrollPaths(); - const { client } = getLoopndrollDatabase(paths.databasePath); - const botTokens = getTelegramBridgeBotTokens(client); - - for (const botToken of botTokens) { - const cursor = getTelegramUpdateCursor(client, botToken); - const updates = await fetchTelegramUpdates( +export async function pollTelegramBridgeBotToken( + paths: LoopndrollPaths, + db: Database, + botToken: string, + dependencies: TelegramBridgePollDependencies = {}, +) { + const fetchUpdatesForToken = dependencies.fetchUpdates ?? fetchTelegramUpdates; + const processUpdate = dependencies.processUpdate ?? processTelegramBridgeUpdate; + const log = dependencies.log ?? appendHookDebugLog; + const cursor = getTelegramUpdateCursor(db, botToken); + let updates: TelegramUpdate[]; + + try { + updates = await fetchUpdatesForToken( botToken, typeof cursor === "number" ? cursor + 1 : undefined, ); - if (updates.length === 0) { - continue; - } + } catch (error) { + await log(paths, { + type: "telegram-bridge", + action: "poll-token-error", + botToken, + error: error instanceof Error ? error.message : String(error), + }); + return; + } - const lastUpdateId = updates.reduce((max, update) => { - return typeof update.update_id === "number" && update.update_id > max - ? update.update_id - : max; - }, cursor ?? -1); + if (updates.length === 0) { + return; + } + + const lastUpdateId = updates.reduce((max, update) => { + return typeof update.update_id === "number" && update.update_id > max ? update.update_id : max; + }, cursor ?? -1); - for (const update of updates) { - await processTelegramBridgeUpdate(paths, client, botToken, update); + for (const update of updates) { + try { + await processUpdate(paths, db, botToken, update); + } catch (error) { + await log(paths, { + type: "telegram-bridge", + action: "poll-update-error", + botToken, + updateId: update.update_id ?? null, + error: error instanceof Error ? error.message : String(error), + }); } + } + + if (lastUpdateId >= 0) { + setTelegramUpdateCursor(db, botToken, lastUpdateId); + } +} - setTelegramUpdateCursor(client, botToken, lastUpdateId); +async function pollTelegramReplies() { + const paths = getLoopndrollPaths(); + const { client } = getLoopndrollDatabase(paths.databasePath); + const botTokens = getTelegramBridgeBotTokens(client); + + for (const botToken of botTokens) { + try { + await pollTelegramBridgeBotToken(paths, client, botToken); + } catch (error) { + await appendHookDebugLog(paths, { + type: "telegram-bridge", + action: "poll-token-unhandled-error", + botToken, + error: error instanceof Error ? error.message : String(error), + }); + } } } diff --git a/src/bun/telegram-control.test.ts b/src/bun/telegram-control.test.ts new file mode 100644 index 0000000..4bc56ec --- /dev/null +++ b/src/bun/telegram-control.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildTelegramPromptReceivedText, + buildTelegramWorkingAckText, + getTelegramRemotePromptDeliveryMode, +} from "./telegram-control"; + +describe("getTelegramRemotePromptDeliveryMode", () => { + test("uses one-shot delivery for await-reply", () => { + expect(getTelegramRemotePromptDeliveryMode("await-reply")).toBe("once"); + }); + + test("keeps persistent delivery for continuous auto-run modes", () => { + expect(getTelegramRemotePromptDeliveryMode("infinite")).toBe("persistent"); + expect(getTelegramRemotePromptDeliveryMode("completion-checks")).toBe("persistent"); + }); +}); + +describe("Telegram ack text", () => { + test("formats received acknowledgements with project-aware labels", () => { + expect( + buildTelegramPromptReceivedText({ + cwd: "/Users/example/Documents/ChiefOfStaff", + sessionRef: "c22", + title: "Fix bridge", + }), + ).toBe( + ["Reply queued for next Codex stop", "[ChiefOfStaff] [C22]", "Thread: Fix bridge"].join("\n"), + ); + }); + + test("formats working acknowledgements with project-aware labels", () => { + expect( + buildTelegramWorkingAckText({ + cwd: "/Users/example/Documents/ChiefOfStaff", + sessionRef: "c22", + title: "Fix bridge", + }), + ).toBe(["Reply delivered to Codex", "[ChiefOfStaff] [C22]", "Thread: Fix bridge"].join("\n")); + }); +}); diff --git a/src/bun/telegram-control.ts b/src/bun/telegram-control.ts new file mode 100644 index 0000000..0afbbdc --- /dev/null +++ b/src/bun/telegram-control.ts @@ -0,0 +1,28 @@ +import type { LoopPreset } from "../shared/app-rpc"; +import { formatTelegramSessionLabel } from "./telegram-output"; + +export function getTelegramRemotePromptDeliveryMode( + preset: LoopPreset | null, +): "once" | "persistent" { + return preset === "await-reply" ? "once" : "persistent"; +} + +export function buildTelegramPromptReceivedText(input: { + cwd?: string | null; + sessionRef?: string | null; + title?: string | null; +}) { + const label = formatTelegramSessionLabel(input); + return label.length > 0 + ? `Reply queued for next Codex stop\n${label}` + : "Reply queued for next Codex stop."; +} + +export function buildTelegramWorkingAckText(input: { + cwd?: string | null; + sessionRef?: string | null; + title?: string | null; +}) { + const label = formatTelegramSessionLabel(input); + return label.length > 0 ? `Reply delivered to Codex\n${label}` : "Reply delivered to Codex."; +} diff --git a/src/bun/telegram-output.test.ts b/src/bun/telegram-output.test.ts new file mode 100644 index 0000000..a943613 --- /dev/null +++ b/src/bun/telegram-output.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildTelegramNotificationChunks, + formatTelegramSessionLabel, + normalizeTelegramOutputText, +} from "./telegram-output"; + +describe("normalizeTelegramOutputText", () => { + test("cleans common markdown noise into chat-friendly text", () => { + const input = [ + "## Plan", + "", + "- [ ] first item", + "- [x] done item", + "", + "**Bold** and [docs](https://example.com/docs)", + "", + "> quoted", + ].join("\n"); + + expect(normalizeTelegramOutputText(input)).toBe( + [ + "Plan", + "- first item", + "- [done] done item", + "", + "Bold and docs (https://example.com/docs)", + "quoted", + ].join("\n"), + ); + }); +}); + +describe("formatTelegramSessionLabel", () => { + test("includes project, session ref and title when available", () => { + expect( + formatTelegramSessionLabel({ + cwd: "/Users/example/Documents/ChiefOfStaff", + sessionRef: "c12", + title: "Fix bridge", + }), + ).toBe("[ChiefOfStaff] [C12]\nThread: Fix bridge"); + }); + + test("marks chats without cwd as projectless", () => { + expect( + formatTelegramSessionLabel({ + cwd: null, + sessionRef: "c9", + title: "Untitled thread", + }), + ).toBe("[Projectless] [C9]\nThread: Untitled thread"); + }); +}); + +describe("buildTelegramNotificationChunks", () => { + test("keeps footer only on the last chunk and adds numbering", () => { + const chunks = buildTelegramNotificationChunks({ + cwd: "/Users/example/Documents/ChiefOfStaff", + sessionRef: "C7", + sessionTitle: "Long report", + message: Array.from({ length: 140 }, () => "paragraph content").join(" "), + preset: "await-reply", + telegramNotificationFooter: "Reply to this message in Telegram to continue this Codex chat.", + maxLength: 220, + }); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks[0]).toContain("[ChiefOfStaff] [C7] (1/"); + expect(chunks[0]).toContain("Thread: Long report\n\n---------\n\nparagraph content"); + expect(chunks[1]).toContain("(2/"); + expect(chunks.at(-1)).toContain( + "Reply to this message in Telegram to continue this Codex chat.", + ); + expect(chunks[0]).not.toContain( + "Reply to this message in Telegram to continue this Codex chat.", + ); + expect(chunks.every((chunk) => chunk.length <= 220)).toBe(true); + }); + + test("does not derive header context from the outgoing assistant message", () => { + const chunks = buildTelegramNotificationChunks({ + cwd: null, + sessionRef: "C8", + sessionTitle: "Fix hook", + message: ["Fix hook", "", "The typecheck is failing in hook-management.ts"].join("\n"), + preset: "await-reply", + telegramNotificationFooter: "Reply to this message in Telegram to continue this Codex chat.", + maxLength: 4096, + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).toContain("[Projectless] [C8]\nThread: Fix hook\n\n---------"); + expect(chunks[0]).not.toContain("Context:"); + }); +}); diff --git a/src/bun/telegram-output.ts b/src/bun/telegram-output.ts new file mode 100644 index 0000000..1438d28 --- /dev/null +++ b/src/bun/telegram-output.ts @@ -0,0 +1,245 @@ +function compactWhitespace(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +export function deriveTelegramProjectLabel(cwd: string | null | undefined) { + if (typeof cwd !== "string") { + return "Projectless"; + } + + const normalized = cwd + .replace(/\\/g, "/") + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (normalized.length === 0) { + return "Projectless"; + } + + return compactWhitespace(normalized[normalized.length - 1] ?? ""); +} + +export function formatTelegramSessionLabel(input: { + cwd?: string | null; + sessionRef?: string | null; + title?: string | null; +}) { + const firstLineSegments: string[] = []; + const projectLabel = deriveTelegramProjectLabel(input.cwd ?? null); + if (projectLabel) { + firstLineSegments.push(`[${projectLabel}]`); + } + + const sessionRef = + typeof input.sessionRef === "string" && input.sessionRef.trim().length > 0 + ? input.sessionRef.trim().toUpperCase() + : null; + if (sessionRef) { + firstLineSegments.push(`[${sessionRef}]`); + } + + const title = + typeof input.title === "string" && input.title.trim().length > 0 + ? `Thread: ${compactWhitespace(input.title)}` + : null; + + const lines = [firstLineSegments.join(" "), title].filter( + (line): line is string => typeof line === "string" && line.length > 0, + ); + + return lines.join("\n"); +} + +function appendTelegramChunkLabel(header: string, chunkLabel: string | null) { + if (!chunkLabel) { + return header; + } + + const [firstLine, ...restLines] = header.split("\n"); + const labeledFirstLine = + typeof firstLine === "string" && firstLine.length > 0 + ? `${firstLine} ${chunkLabel}` + : chunkLabel; + + return [labeledFirstLine, ...restLines].join("\n"); +} + +export function normalizeTelegramOutputText(message: string | null | undefined) { + const normalized = String(message ?? "") + .replace(/\r\n/g, "\n") + .replace(/\t/g, " ") + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1 ($2)") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*([-*])\s+\[ \]\s+/gm, "- ") + .replace(/^\s*([-*])\s+\[[xX]\]\s+/gm, "- [done] ") + .replace(/^\s*-\s*$/gm, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return normalized; +} + +function buildTelegramNotificationFooter( + sessionRef: string | null | undefined, + preset: string | null | undefined, + telegramNotificationFooter: string, +) { + const replyCommandFooter = + typeof sessionRef === "string" && sessionRef.trim().length > 0 + ? `Or send /reply ${sessionRef.trim().toUpperCase()} your message.` + : null; + + const segments: string[] = []; + if (preset === "await-reply" || preset === "completion-checks") { + segments.push("---------", telegramNotificationFooter); + } else if ( + preset === "infinite" || + preset === "max-turns-1" || + preset === "max-turns-2" || + preset === "max-turns-3" + ) { + segments.push( + "---------", + "Reply to this message in Telegram to replace the prompt that will keep being sent to this Codex chat.", + ); + } + + if (replyCommandFooter) { + segments.push(replyCommandFooter); + } + + return segments.join("\n\n"); +} + +function splitTelegramMessageChunk(text: string, maxLength: number) { + if (text.length <= maxLength) { + return { + chunk: text, + rest: "", + }; + } + + const candidateIndexes = [ + text.lastIndexOf("\n\n", maxLength), + text.lastIndexOf("\n", maxLength), + text.lastIndexOf(" ", maxLength), + ].filter((index) => index >= 0); + + let splitIndex = candidateIndexes.length > 0 ? Math.max(...candidateIndexes) : -1; + if (splitIndex < Math.floor(maxLength * 0.5)) { + splitIndex = maxLength; + } + + const chunk = text.slice(0, splitIndex).trimEnd(); + const rest = text.slice(splitIndex).trimStart(); + + if (chunk.length === 0) { + return { + chunk: text.slice(0, maxLength).trimEnd(), + rest: text.slice(maxLength).trimStart(), + }; + } + + return { chunk, rest }; +} + +export function buildTelegramNotificationChunks(input: { + cwd?: string | null; + sessionRef?: string | null; + sessionTitle?: string | null; + message?: string | null; + preset?: string | null; + telegramNotificationFooter: string; + maxLength: number; +}) { + const title = compactWhitespace(input.sessionTitle ?? ""); + const header = formatTelegramSessionLabel({ + cwd: input.cwd ?? null, + sessionRef: input.sessionRef ?? null, + title, + }); + const body = normalizeTelegramOutputText(input.message ?? ""); + if (body.length === 0) { + return []; + } + + const footer = buildTelegramNotificationFooter( + input.sessionRef ?? null, + input.preset ?? null, + input.telegramNotificationFooter, + ); + const baseBodyLimit = Math.max(1, input.maxLength); + const bodyChunks: string[] = []; + let remaining = body; + + while (remaining.length > 0) { + const { chunk, rest } = splitTelegramMessageChunk(remaining, baseBodyLimit); + bodyChunks.push(chunk); + remaining = rest; + } + + let totalChunks = bodyChunks.length; + const renderChunk = (chunk: string, index: number, count: number) => { + const chunkLabel = count > 1 ? `(${index + 1}/${count})` : null; + const segments: string[] = []; + + if (index === 0) { + if (header) { + segments.push(appendTelegramChunkLabel(header, chunkLabel)); + } else if (chunkLabel) { + segments.push(chunkLabel); + } + } else if (chunkLabel) { + segments.push(chunkLabel); + } + + if (index === 0 && header) { + segments.push("---------"); + } + + segments.push(chunk); + + if (index === count - 1 && footer.length > 0) { + segments.push(footer); + } + + return segments.join("\n\n"); + }; + + while (true) { + const rendered = bodyChunks.map((chunk, index) => renderChunk(chunk, index, totalChunks)); + const oversizedIndex = rendered.findIndex((chunk) => chunk.length > input.maxLength); + if (oversizedIndex === -1) { + return rendered; + } + + const currentChunk = bodyChunks[oversizedIndex] ?? ""; + const renderedChunk = rendered[oversizedIndex] ?? ""; + const overflow = Math.max(1, renderedChunk.length - input.maxLength); + const splitLimit = Math.max( + 1, + currentChunk.length - overflow - Math.ceil(input.maxLength * 0.1), + ); + const { chunk, rest } = splitTelegramMessageChunk(currentChunk, splitLimit); + bodyChunks.splice(oversizedIndex, 1, chunk, rest); + totalChunks = bodyChunks.length; + } +} + +export const TELEGRAM_OUTPUT_HOOK_SOURCE = [ + compactWhitespace, + deriveTelegramProjectLabel, + formatTelegramSessionLabel, + appendTelegramChunkLabel, + normalizeTelegramOutputText, + buildTelegramNotificationFooter, + splitTelegramMessageChunk, + buildTelegramNotificationChunks, +] + .map((fn) => fn.toString()) + .join("\n\n"); diff --git a/src/bun/telegram-utils.ts b/src/bun/telegram-utils.ts index a3c88c5..dbf9858 100644 --- a/src/bun/telegram-utils.ts +++ b/src/bun/telegram-utils.ts @@ -1,5 +1,6 @@ import { type Database } from "bun:sqlite"; import type { TelegramChatOption } from "../shared/app-rpc"; +import { filterTelegramDirectMessageChats } from "../shared/telegram-chat-policy"; import { getLoopndrollDatabase } from "./db/client"; import { TELEGRAM_ALLOWED_UPDATES, @@ -182,7 +183,11 @@ function readKnownTelegramChats(db: Database, botToken: string): TelegramChatOpt })); } -export function upsertKnownTelegramChats(db: Database, botToken: string, chats: TelegramChatOption[]) { +export function upsertKnownTelegramChats( + db: Database, + botToken: string, + chats: TelegramChatOption[], +) { if (chats.length === 0) { return; } @@ -250,7 +255,7 @@ export async function getTelegramChats( const refreshedCachedChats = await enrichTelegramChats(normalizedBotToken, cachedChats); upsertKnownTelegramChats(client, normalizedBotToken, refreshedCachedChats); if (!waitForUpdates) { - return readKnownTelegramChats(client, normalizedBotToken); + return filterTelegramDirectMessageChats(readKnownTelegramChats(client, normalizedBotToken)); } const params = new URLSearchParams({ @@ -274,10 +279,13 @@ export async function getTelegramChats( const discoveredChats = collectTelegramChatsFromUpdates(updates); const enrichedChats = await enrichTelegramChats(normalizedBotToken, discoveredChats); upsertKnownTelegramChats(client, normalizedBotToken, enrichedChats); - return readKnownTelegramChats(client, normalizedBotToken); + return filterTelegramDirectMessageChats(readKnownTelegramChats(client, normalizedBotToken)); } -export async function fetchTelegramUpdates(botToken: string, offset?: number): Promise { +export async function fetchTelegramUpdates( + botToken: string, + offset?: number, +): Promise { const params = new URLSearchParams(); if (typeof offset === "number") { params.set("offset", String(offset)); @@ -320,6 +328,8 @@ export async function sendTelegramBridgeMessage(botToken: string, chatId: string if (!payload.ok) { throw new Error(payload.description || "Telegram sendMessage failed."); } + + return payload; } async function fetchTelegramChatDetails(botToken: string, chatId: string) { diff --git a/src/bun/thread-name-artifact.test.ts b/src/bun/thread-name-artifact.test.ts new file mode 100644 index 0000000..559581e --- /dev/null +++ b/src/bun/thread-name-artifact.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { + looksInternalThreadNameArtifact, + looksStaleStoredThreadName, +} from "./thread-name-artifact"; + +describe("looksInternalThreadNameArtifact", () => { + test("flags unmistakable prompt and instruction artifacts", () => { + expect(looksInternalThreadNameArtifact("You are a helpful assistant.")).toBe(true); + expect(looksInternalThreadNameArtifact("- Use `js_repl` for Node-backed JavaScript")).toBe( + true, + ); + expect( + looksInternalThreadNameArtifact("# AGENTS.md instructions for /Users/test/project"), + ).toBe(true); + }); + + test("keeps normal human-facing thread names visible", () => { + expect(looksInternalThreadNameArtifact("Build freelancer pricing engine")).toBe(false); + expect(looksInternalThreadNameArtifact("Memory Writing Agent: Phase 2 (Consolidation)")).toBe( + false, + ); + }); +}); + +describe("looksStaleStoredThreadName", () => { + test("keeps broad stale detection for refresh candidates", () => { + expect(looksStaleStoredThreadName(null)).toBe(true); + expect(looksStaleStoredThreadName("## Memory Writing Agent: Phase 2")).toBe(true); + expect(looksStaleStoredThreadName("- Some prompt artifact")).toBe(true); + expect(looksStaleStoredThreadName("Planeia setup local Open WebUI")).toBe(false); + }); +}); diff --git a/src/bun/thread-name-artifact.ts b/src/bun/thread-name-artifact.ts new file mode 100644 index 0000000..5cf19f3 --- /dev/null +++ b/src/bun/thread-name-artifact.ts @@ -0,0 +1,40 @@ +function normalizeThreadName(value: string | null | undefined) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function looksInternalThreadNameArtifact(value: string | null | undefined) { + const normalized = normalizeThreadName(value); + if (normalized === null) { + return false; + } + + const lowerCased = normalized.toLowerCase(); + + return ( + lowerCased.includes("you are a helpful assistant.") || + lowerCased.includes("you are a memory writing agent.") || + lowerCased.includes("agents.md instructions") || + lowerCased.includes("javascript repl") || + lowerCased.includes("`js_repl`") || + lowerCased.includes("") || + lowerCased.startsWith("your job:") + ); +} + +export function looksStaleStoredThreadName(value: string | null | undefined) { + const normalized = normalizeThreadName(value); + if (normalized === null) { + return true; + } + + return ( + looksInternalThreadNameArtifact(normalized) || + normalized.startsWith("## ") || + normalized.startsWith("- ") + ); +} diff --git a/src/bun/thread-name-refresh.test.ts b/src/bun/thread-name-refresh.test.ts new file mode 100644 index 0000000..45d56b9 --- /dev/null +++ b/src/bun/thread-name-refresh.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { + ORPHANED_THREAD_PRUNE_RELAUNCH_LIMIT, + collectCanonicalThreadNameUpdates, + collectOrphanedThreadArtifactActions, +} from "./thread-name-refresh"; + +describe("collectCanonicalThreadNameUpdates", () => { + test("refreshes stale stored names from canonical discovery results", () => { + const updates = collectCanonicalThreadNameUpdates( + [ + { + threadId: "thr_stale", + cwd: "/tmp/project", + threadName: "sera que já está?", + transcriptPath: null, + }, + { + threadId: "thr_ok", + cwd: "/tmp/project", + threadName: "Correct thread name", + transcriptPath: null, + }, + { + threadId: "thr_missing", + cwd: "/tmp/project", + threadName: null, + transcriptPath: null, + }, + ], + [ + { + threadId: "thr_stale", + threadName: "Canonical thread name", + cwd: "/tmp/project", + }, + { + threadId: "thr_ok", + threadName: "Correct thread name", + cwd: "/tmp/project", + }, + { + threadId: "thr_missing", + threadName: "Recovered title", + cwd: "/tmp/project", + }, + ], + ); + + expect(updates).toEqual([ + { + threadId: "thr_stale", + threadName: "Canonical thread name", + }, + { + threadId: "thr_missing", + threadName: "Recovered title", + }, + ]); + }); + + test("ignores empty or unchanged canonical names", () => { + const updates = collectCanonicalThreadNameUpdates( + [ + { + threadId: "thr_same", + cwd: "/tmp/project", + threadName: "Already right", + transcriptPath: null, + }, + { + threadId: "thr_empty", + cwd: "/tmp/project", + threadName: "Keep this", + transcriptPath: null, + }, + ], + [ + { + threadId: "thr_same", + threadName: " Already right ", + cwd: "/tmp/project", + }, + { + threadId: "thr_empty", + threadName: " ", + cwd: "/tmp/project", + }, + ], + ); + + expect(updates).toEqual([]); + }); +}); + +describe("collectOrphanedThreadArtifactActions", () => { + test("increments hidden orphan artifacts and resets recovered rows", () => { + const actions = collectOrphanedThreadArtifactActions( + [ + { + threadId: "thr_hidden", + cwd: "/tmp/project", + threadName: "You are a helpful assistant.", + orphanedRefreshMissCount: 1, + transcriptPath: null, + }, + { + threadId: "thr_recovered", + cwd: "/tmp/project", + threadName: "Build freelancer pricing engine", + orphanedRefreshMissCount: 2, + transcriptPath: null, + }, + ], + [ + { + threadId: "thr_recovered", + threadName: "Build freelancer pricing engine", + cwd: "/tmp/project", + }, + ], + [], + ); + + expect(actions).toEqual([ + { + type: "increment", + threadId: "thr_hidden", + nextMissCount: 2, + }, + { + type: "reset", + threadId: "thr_recovered", + }, + ]); + }); + + test("hard deletes the artifact after the prune limit", () => { + const actions = collectOrphanedThreadArtifactActions( + [ + { + threadId: "thr_hidden", + cwd: "/tmp/project", + threadName: "You are a helpful assistant.", + orphanedRefreshMissCount: ORPHANED_THREAD_PRUNE_RELAUNCH_LIMIT - 1, + transcriptPath: null, + }, + ], + [], + [], + ); + + expect(actions).toEqual([ + { + type: "delete", + threadId: "thr_hidden", + }, + ]); + }); +}); diff --git a/src/bun/thread-name-refresh.ts b/src/bun/thread-name-refresh.ts new file mode 100644 index 0000000..1f59944 --- /dev/null +++ b/src/bun/thread-name-refresh.ts @@ -0,0 +1,300 @@ +import { readFile } from "node:fs/promises"; +import type { Database } from "bun:sqlite"; +import type { LoopSession } from "../shared/app-rpc"; +import { + createSpawnedCodexAppServerTransport, + listThreadsForCwdViaCodexAppServer, + type CanonicalThreadDiscoveryRecord, +} from "./codex-app-server-client"; +import { + looksInternalThreadNameArtifact, + looksStaleStoredThreadName, +} from "./thread-name-artifact"; +import { deriveThreadNameFromTranscript } from "./thread-name-transcript"; + +type ThreadNameRefreshCandidate = Pick< + LoopSession, + "threadId" | "cwd" | "threadName" | "transcriptPath" +>; + +type ThreadNameRefreshUpdate = { + threadId: string; + threadName: string; +}; + +type ThreadOrphanRefreshCandidate = ThreadNameRefreshCandidate & { + orphanedRefreshMissCount: number; +}; + +type ThreadOrphanAction = + | { + type: "reset"; + threadId: string; + } + | { + type: "increment"; + threadId: string; + nextMissCount: number; + } + | { + type: "delete"; + threadId: string; + }; + +export type ThreadNameRefreshResult = { + refreshedCount: number; + orphanedMissCountUpdated: number; + prunedCount: number; + resetCount: number; +}; + +export const ORPHANED_THREAD_PRUNE_RELAUNCH_LIMIT = 3; + +function normalizeThreadName(value: string | null | undefined) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeCwd(value: string | null | undefined) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readTranscriptSessionMetaCwd(transcriptPath: string | null | undefined) { + const normalizedTranscriptPath = normalizeCwd(transcriptPath); + if (normalizedTranscriptPath === null) { + return null; + } + + try { + const raw = await readFile(normalizedTranscriptPath, "utf8"); + const firstLine = raw.split("\n", 1)[0]?.trim(); + if (!firstLine) { + return null; + } + + const parsed = JSON.parse(firstLine) as { + type?: string; + payload?: { cwd?: string | null }; + }; + if (parsed.type !== "session_meta") { + return null; + } + + return normalizeCwd(parsed.payload?.cwd); + } catch { + return null; + } +} + +async function collectDiscoveryCwds(candidates: ThreadNameRefreshCandidate[]) { + const discoveryCwds = new Set(); + + for (const candidate of candidates) { + const storedCwd = normalizeCwd(candidate.cwd); + if (storedCwd !== null) { + discoveryCwds.add(storedCwd); + } + + const transcriptCwd = await readTranscriptSessionMetaCwd(candidate.transcriptPath); + if (transcriptCwd !== null) { + discoveryCwds.add(transcriptCwd); + } + } + + return [...discoveryCwds]; +} + +export function collectCanonicalThreadNameUpdates( + candidates: ThreadNameRefreshCandidate[], + discoveredThreads: CanonicalThreadDiscoveryRecord[], +) { + const discoveredByThreadId = new Map( + discoveredThreads.map((thread) => [thread.threadId, normalizeThreadName(thread.threadName)]), + ); + + const updates: ThreadNameRefreshUpdate[] = []; + + for (const candidate of candidates) { + const nextThreadName = discoveredByThreadId.get(candidate.threadId) ?? null; + const currentThreadName = normalizeThreadName(candidate.threadName); + + if (nextThreadName === null || nextThreadName === currentThreadName) { + continue; + } + + updates.push({ + threadId: candidate.threadId, + threadName: nextThreadName, + }); + } + + return updates; +} + +async function collectTranscriptThreadNameUpdates( + candidates: ThreadNameRefreshCandidate[], + canonicalUpdates: ThreadNameRefreshUpdate[], +) { + const canonicalUpdatedThreadIds = new Set(canonicalUpdates.map((update) => update.threadId)); + const updates: ThreadNameRefreshUpdate[] = []; + + for (const candidate of candidates) { + if (canonicalUpdatedThreadIds.has(candidate.threadId)) { + continue; + } + + if (!looksStaleStoredThreadName(candidate.threadName)) { + continue; + } + + const derivedThreadName = await deriveThreadNameFromTranscript(candidate.transcriptPath); + const currentThreadName = normalizeThreadName(candidate.threadName); + + if (derivedThreadName === null || derivedThreadName === currentThreadName) { + continue; + } + + updates.push({ + threadId: candidate.threadId, + threadName: derivedThreadName, + }); + } + + return updates; +} + +function getEffectiveThreadName( + candidate: Pick, + updates: ThreadNameRefreshUpdate[], +) { + const matchingUpdate = updates.find((update) => update.threadId === candidate.threadId); + return matchingUpdate?.threadName ?? candidate.threadName; +} + +export function collectOrphanedThreadArtifactActions( + candidates: ThreadOrphanRefreshCandidate[], + discoveredThreads: CanonicalThreadDiscoveryRecord[], + updates: ThreadNameRefreshUpdate[], + pruneRelaunchLimit = ORPHANED_THREAD_PRUNE_RELAUNCH_LIMIT, +) { + const discoveredThreadIds = new Set(discoveredThreads.map((thread) => thread.threadId)); + const actions: ThreadOrphanAction[] = []; + + for (const candidate of candidates) { + const effectiveThreadName = getEffectiveThreadName(candidate, updates); + const canVerifyCanonicalAbsence = normalizeCwd(candidate.cwd) !== null; + const looksLikeHiddenArtifact = looksInternalThreadNameArtifact(effectiveThreadName); + const isMissingCanonically = !discoveredThreadIds.has(candidate.threadId); + const shouldCountAsOrphanedArtifact = + canVerifyCanonicalAbsence && looksLikeHiddenArtifact && isMissingCanonically; + + if (shouldCountAsOrphanedArtifact) { + const nextMissCount = candidate.orphanedRefreshMissCount + 1; + actions.push( + nextMissCount >= pruneRelaunchLimit + ? { type: "delete", threadId: candidate.threadId } + : { + type: "increment", + threadId: candidate.threadId, + nextMissCount, + }, + ); + continue; + } + + if (candidate.orphanedRefreshMissCount > 0) { + actions.push({ + type: "reset", + threadId: candidate.threadId, + }); + } + } + + return actions; +} + +export async function refreshCanonicalThreadNames( + db: Database, + listThreadsForCwd = async (cwd: string) => { + const transport = await createSpawnedCodexAppServerTransport(); + try { + return await listThreadsForCwdViaCodexAppServer(transport, cwd); + } finally { + await transport.close(); + } + }, +) { + const candidates = db + .query( + `select + thread_id as threadId, + cwd, + thread_name as threadName, + orphaned_refresh_miss_count as orphanedRefreshMissCount, + transcript_path as transcriptPath + from sessions`, + ) + .all() as ThreadOrphanRefreshCandidate[]; + + const discoveryCwds = await collectDiscoveryCwds(candidates); + const discoveredThreads: CanonicalThreadDiscoveryRecord[] = []; + + for (const cwd of discoveryCwds) { + discoveredThreads.push(...(await listThreadsForCwd(cwd))); + } + + const canonicalUpdates = collectCanonicalThreadNameUpdates(candidates, discoveredThreads); + const transcriptUpdates = await collectTranscriptThreadNameUpdates(candidates, canonicalUpdates); + const updates = [...canonicalUpdates, ...transcriptUpdates]; + const orphanActions = collectOrphanedThreadArtifactActions( + candidates, + discoveredThreads, + updates, + ); + + for (const update of updates) { + db.query("update sessions set thread_name = ? where thread_id = ?").run( + update.threadName, + update.threadId, + ); + } + + let orphanedMissCountUpdated = 0; + let prunedCount = 0; + let resetCount = 0; + + for (const action of orphanActions) { + if (action.type === "delete") { + db.query("delete from sessions where thread_id = ?").run(action.threadId); + prunedCount += 1; + continue; + } + + db.query("update sessions set orphaned_refresh_miss_count = ? where thread_id = ?").run( + action.type === "increment" ? action.nextMissCount : 0, + action.threadId, + ); + + if (action.type === "increment") { + orphanedMissCountUpdated += 1; + } else { + resetCount += 1; + } + } + + return { + refreshedCount: updates.length, + orphanedMissCountUpdated, + prunedCount, + resetCount, + } satisfies ThreadNameRefreshResult; +} diff --git a/src/bun/thread-name-transcript.test.ts b/src/bun/thread-name-transcript.test.ts new file mode 100644 index 0000000..5345d52 --- /dev/null +++ b/src/bun/thread-name-transcript.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { deriveThreadNameFromUserText } from "./thread-name-transcript"; + +describe("deriveThreadNameFromUserText", () => { + test("skips AGENTS boilerplate and uses the first meaningful user heading", () => { + const result = deriveThreadNameFromUserText( + [ + "# AGENTS.md instructions for /Users/test/.codex/memories", + "", + "", + "## JavaScript REPL (Node)", + "## Memory Writing Agent: Phase 2 (Consolidation)", + "", + "You are a Memory Writing Agent.", + ].join("\n"), + ); + + expect(result).toBe("Memory Writing Agent: Phase 2 (Consolidation)"); + }); + + test("returns null when no meaningful user line exists", () => { + const result = deriveThreadNameFromUserText( + ["# AGENTS.md instructions for /tmp/project", "", "", "Your job:"].join("\n"), + ); + + expect(result).toBeNull(); + }); + + test("does not classify regular Java prompts as boilerplate", () => { + const result = deriveThreadNameFromUserText( + ["Java migration plan", "## JavaScript REPL (Node)"].join("\n"), + ); + + expect(result).toBe("Java migration plan"); + }); +}); diff --git a/src/bun/thread-name-transcript.ts b/src/bun/thread-name-transcript.ts new file mode 100644 index 0000000..9941d21 --- /dev/null +++ b/src/bun/thread-name-transcript.ts @@ -0,0 +1,93 @@ +import { readFile } from "node:fs/promises"; + +function normalizeLine(value: string) { + return value + .replace(/^#{1,6}\s+/, "") + .replace(/\s+/g, " ") + .trim(); +} + +function isInstructionBoilerplate(line: string) { + const normalized = line.toLowerCase(); + return ( + normalized.startsWith("agents.md instructions") || + /^javascript\s+repl\b/.test(normalized) || + normalized === "" || + normalized === "" || + normalized.startsWith("you are a memory writing agent.") || + normalized.startsWith("your job:") + ); +} + +export function deriveThreadNameFromUserText(text: string | null | undefined) { + if (typeof text !== "string") { + return null; + } + + for (const rawLine of text.split("\n")) { + const line = normalizeLine(rawLine); + if (line.length === 0 || isInstructionBoilerplate(line)) { + continue; + } + + return line.slice(0, 120).trim(); + } + + return null; +} + +export async function deriveThreadNameFromTranscript(transcriptPath: string | null | undefined) { + if (typeof transcriptPath !== "string" || transcriptPath.trim().length === 0) { + return null; + } + + try { + const raw = await readFile(transcriptPath, "utf8"); + const lines = raw.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + const parsed = JSON.parse(trimmed) as { + type?: string; + payload?: { + type?: string; + role?: string; + content?: Array<{ text?: string; type?: string }>; + }; + }; + + if (parsed.type !== "response_item") { + continue; + } + + if (parsed.payload?.type !== "message" || parsed.payload.role !== "user") { + continue; + } + + const userText = parsed.payload.content + ?.map((item) => (typeof item.text === "string" ? item.text : null)) + .filter((value): value is string => value !== null) + .join("\n"); + + if ( + typeof userText === "string" && + (userText.includes("AGENTS.md instructions") || userText.includes("")) + ) { + continue; + } + + const derived = deriveThreadNameFromUserText(userText); + if (derived) { + return derived; + } + } + } catch { + return null; + } + + return null; +} diff --git a/src/components/chat-card.tsx b/src/components/chat-card.tsx index 347e6da..9976077 100644 --- a/src/components/chat-card.tsx +++ b/src/components/chat-card.tsx @@ -76,6 +76,7 @@ const CHAT_CARD_THEME_CLASSES: Record< type ChatCardProps = { title?: ReactNode; + description?: ReactNode; marker?: ReactNode; markerContainerClassName?: string; theme?: ChatCardTheme; @@ -93,6 +94,7 @@ type ChatCardProps = { export function ChatCard({ title, + description, marker, markerContainerClassName, theme, @@ -122,29 +124,40 @@ export function ChatCard({ )} > {placeholder ? ( ) : ( -
-
- {marker} + <> +
+
+ {marker} +
+ + {title} +
- - {title} - -
+ {description ? ( +

+ {description} +

+ ) : null} + )} @@ -156,16 +169,11 @@ export function ChatCard({ footerClassName, )} > -
- {footerStart} -
+
{footerStart}
+
+ ); +} + +function SettingsSections({ + model, + update, +}: { + model: ReturnType; + update: ReturnType; +}) { + return ( +
+ { + void model.saveHandlers.saveDefaultPrompt(); + }} + /> + + + + { + void update.applyUpdate(); + }} + onCheckForUpdates={() => { + void update.checkForUpdates(); + }} + onDownloadUpdate={() => { + void update.downloadUpdate(); + }} + /> + { + void model.updateMirrorEnabled(enabled); + }} + /> + { + void model.migrateSecrets(); + }} + /> +
+ ); +} + +function NotificationsSettingsSection({ + model, +}: { + model: ReturnType; +}) { + return ( + <> + { + void handleExternalLinkClick( + event, + "https://github.com/lnikell/loopndroll?tab=readme-ov-file#telegram-commands", + ); + }} + onEdit={model.openEditNotificationDialog} + onRemove={(notificationId) => { + void model.removeNotification(notificationId); + }} + /> + + + ); +} + +function CompletionChecksSettingsSection({ + model, +}: { + model: ReturnType; +}) { + return ( + { + void handleExternalLinkClick( + event, + "https://github.com/lnikell/loopndroll?tab=readme-ov-file#4-completion-checks", + ); + }} + onEdit={model.openEditCompletionCheckDialog} + onRemove={(completionCheckId) => { + void model.removeCompletionCheck(completionCheckId); + }} + /> + ); +} + +function HookSettingsSection({ model }: { model: ReturnType }) { + return ( + { + void model.uninstallHooks(); + }} + onPauseLoopndroll={() => { + void model.pauseLoopndroll(); + }} + onRegisterHooks={() => { + void model.installHooks(); + }} + onRevealHooksFile={() => { + void revealHooksFile(); + }} + onResumeLoopndroll={() => { + void model.resumeLoopndroll(); + }} + onStartLoopndroll={() => { + void model.startLoopndroll(); + }} + onStopLoopndroll={() => { + void model.stopLoopndroll(); + }} + /> + ); +} + function SettingsContent({ model, navigate, + update, }: { model: ReturnType; navigate: ReturnType; + update: ReturnType; }) { return (
-
- -
+
-

Settings

-
- {model.errorMessage ?

{model.errorMessage}

: null} -
- { - void model.saveHandlers.saveDefaultPrompt(); - }} - /> - { - void handleExternalLinkClick( - event, - "https://github.com/lnikell/loopndroll?tab=readme-ov-file#telegram-commands", - ); - }} - onEdit={model.openEditNotificationDialog} - onRemove={(notificationId) => { - void model.removeNotification(notificationId); - }} - /> - { - void handleExternalLinkClick( - event, - "https://github.com/lnikell/loopndroll?tab=readme-ov-file#4-completion-checks", - ); - }} - onEdit={model.openEditCompletionCheckDialog} - onRemove={(completionCheckId) => { - void model.removeCompletionCheck(completionCheckId); - }} - /> - { - void model.uninstallHooks(); - }} - onRegisterHooks={() => { - void model.installHooks(); - }} - onRevealHooksFile={() => { - void revealHooksFile(); - }} - /> +

+ Settings +

+ {model.errorMessage ? ( +

{model.errorMessage}

+ ) : null} +
); @@ -138,11 +239,12 @@ function SettingsContent({ export function SettingsRoute() { const navigate = useNavigate(); const model = useSettingsRouteModel(); + const update = useAppUpdate(); return ( <> - + ); } diff --git a/src/pages/settings/common.ts b/src/pages/settings/common.ts index d6e039a..a8ce9f4 100644 --- a/src/pages/settings/common.ts +++ b/src/pages/settings/common.ts @@ -1,6 +1,10 @@ import { type MouseEvent } from "react"; import { z } from "zod/v4"; import { openExternalUrl, type LoopNotification, type TelegramChatOption } from "@/lib/loopndroll"; +import { validateTelegramNotificationChatId } from "@/shared/telegram-chat-policy"; + +export const TELEGRAM_BOT_TOKEN_KEYCHAIN_REF_PREFIX = "keychain://loopndroll/telegram-bot-token/"; +export const SLACK_WEBHOOK_URL_KEYCHAIN_REF_PREFIX = "keychain://loopndroll/slack-webhook-url/"; export const settingsSchema = z.object({ defaultPrompt: z @@ -23,11 +27,17 @@ export const notificationSchema = z .superRefine((values, context) => { if (values.channel === "slack") { if (values.webhookUrl.trim().length === 0) { - context.addIssue({ code: "custom", message: "Webhook URL is required.", path: ["webhookUrl"] }); + context.addIssue({ + code: "custom", + message: "Webhook URL is required.", + path: ["webhookUrl"], + }); return; } - if (!z.string().url().safeParse(values.webhookUrl.trim()).success) { + const webhookUrl = values.webhookUrl.trim(); + const isKeychainRef = webhookUrl.startsWith(SLACK_WEBHOOK_URL_KEYCHAIN_REF_PREFIX); + if (!isKeychainRef && !z.string().url().safeParse(webhookUrl).success) { context.addIssue({ code: "custom", message: "Webhook URL must be a valid URL.", @@ -48,6 +58,16 @@ export const notificationSchema = z message: "Select a Telegram chat.", path: ["telegramChatId"], }); + return; + } + + const chatError = validateTelegramNotificationChatId(values.telegramChatId.trim()); + if (chatError) { + context.addIssue({ + code: "custom", + message: chatError, + path: ["telegramChatId"], + }); } }); @@ -139,10 +159,6 @@ export function mergeTelegramChats( ); } -export function inferTelegramChatKind(chatId: string): TelegramChatOption["kind"] { - return chatId.trim().startsWith("-") ? "group" : "dm"; -} - export function getTelegramChatErrorMessage(error: unknown) { return error instanceof Error ? error.message : "Failed to load Telegram chats."; } @@ -159,10 +175,7 @@ export function parseCommandsText(commandsText: string) { .filter((line) => line.length > 0); } -export async function handleExternalLinkClick( - event: MouseEvent, - url: string, -) { +export async function handleExternalLinkClick(event: MouseEvent, url: string) { event.preventDefault(); const opened = await openExternalUrl(url); diff --git a/src/pages/settings/dialogs.tsx b/src/pages/settings/dialogs.tsx index 3ca25d4..74f9377 100644 --- a/src/pages/settings/dialogs.tsx +++ b/src/pages/settings/dialogs.tsx @@ -82,7 +82,7 @@ function TelegramBotTokenField({ { form.setValue("telegramChatId", ""); @@ -134,21 +134,28 @@ function TelegramChatField({ ? "Enter a token to load chats" : isLoadingTelegramChats ? "Loading chats..." - : "No chats found"; + : "No direct-message chats found"; return ( Chat item.value === value.value} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value} onValueChange={(chat) => { - form.setValue("telegramChatId", chat?.chatId ?? "", { shouldDirty: true, shouldValidate: true }); + form.setValue("telegramChatId", chat?.chatId ?? "", { + shouldDirty: true, + shouldValidate: true, + }); form.setValue("telegramChatUsername", chat?.username ?? "", { shouldDirty: true }); - form.setValue("telegramChatDisplayName", chat?.displayName ?? "", { shouldDirty: true }); + form.setValue("telegramChatDisplayName", chat?.displayName ?? "", { + shouldDirty: true, + }); form.clearErrors("telegramChatId"); }} value={selectedTelegramChat} @@ -160,7 +167,7 @@ function TelegramChatField({ ? "Enter token first" : isLoadingTelegramChats ? "Loading chats..." - : "Search chats" + : "Search direct messages" } /> @@ -174,7 +181,9 @@ function TelegramChatField({ - Send a message in the chat with the bot, and it will appear here. + + Send the bot a direct message, and it will appear here. Groups and channels are ignored. + {shouldShowTelegramChatsError ? ( {telegramChatsError} ) : telegramChatIdError ? ( @@ -218,6 +227,21 @@ function TelegramFields(props: { ); } +function NotificationDialogFooter(props: { editingNotificationId: string | null }) { + return ( + + + + + + + ); +} + export function NotificationDialog(props: { botTokenError: string | undefined; editingNotificationId: string | null; @@ -242,7 +266,9 @@ export function NotificationDialog(props: {
- {props.editingNotificationId ? "Edit Notification" : "Add Notification"} + + {props.editingNotificationId ? "Edit Notification" : "Add Notification"} + @@ -303,12 +329,7 @@ export function NotificationDialog(props: { /> )} - - - - - - +
@@ -328,9 +349,12 @@ export function CompletionCheckDialog(props: {
- {props.editingCompletionCheckId ? "Edit Completion Check" : "Add Completion Check"} + + {props.editingCompletionCheckId ? "Edit Completion Check" : "Add Completion Check"} + - Create reusable command groups that Completion checks mode runs before Codex is allowed to finish. + Create reusable command groups that Completion checks mode runs before Codex is + allowed to finish. @@ -360,16 +384,23 @@ export function CompletionCheckDialog(props: { }, })} /> - Enter one shell command per line. Commands run sequentially and stop on the first failure. + + Enter one shell command per line. Commands run sequentially and stop on the first + failure. + {props.commandsError ? {props.commandsError} : null} - + - +
diff --git a/src/pages/settings/model.ts b/src/pages/settings/model.ts index 37d0d1b..a2d1129 100644 --- a/src/pages/settings/model.ts +++ b/src/pages/settings/model.ts @@ -12,15 +12,19 @@ import { createEmptyCompletionCheckValues, createEmptyNotificationValues, getTelegramChatErrorMessage, - inferTelegramChatKind, isTransientTelegramChatError, mergeTelegramChats, notificationSchema, parseCommandsText, + toTelegramChatItem, type CompletionCheckFormValues, type NotificationFormValues, type SettingsFormValues, } from "./common"; +import { + filterTelegramDirectMessageChats, + inferTelegramChatKind, +} from "@/shared/telegram-chat-policy"; function createDefaultPromptSubmitHandler(args: { savePrompt: ReturnType["savePrompt"]; @@ -201,14 +205,25 @@ function useDialogResetEffects(args: { args.setTelegramChatsError(null); args.setIsLoadingTelegramChats(false); } - }, [args.isNotificationDialogOpen, args.notificationForm, args.setEditingNotificationId, args.setIsLoadingTelegramChats, args.setTelegramChats, args.setTelegramChatsError]); + }, [ + args.isNotificationDialogOpen, + args.notificationForm, + args.setEditingNotificationId, + args.setIsLoadingTelegramChats, + args.setTelegramChats, + args.setTelegramChatsError, + ]); useEffect(() => { if (!args.isCompletionCheckDialogOpen) { args.completionCheckForm.reset(createEmptyCompletionCheckValues()); args.setEditingCompletionCheckId(null); } - }, [args.completionCheckForm, args.isCompletionCheckDialogOpen, args.setEditingCompletionCheckId]); + }, [ + args.completionCheckForm, + args.isCompletionCheckDialogOpen, + args.setEditingCompletionCheckId, + ]); } function useTelegramChatPolling(args: { @@ -286,7 +301,14 @@ function useTelegramChatPolling(args: { cancelled = true; window.clearTimeout(timeoutId); }; - }, [args.isNotificationDialogOpen, args.normalizedNotificationBotToken, args.notificationChannel, args.setIsLoadingTelegramChats, args.setTelegramChats, args.setTelegramChatsError]); + }, [ + args.isNotificationDialogOpen, + args.normalizedNotificationBotToken, + args.notificationChannel, + args.setIsLoadingTelegramChats, + args.setTelegramChats, + args.setTelegramChatsError, + ]); } function useSettingsForms() { @@ -359,7 +381,9 @@ function buildSelectedTelegramChat(args: { notificationTelegramChatDisplayName: string; notificationTelegramChatId: string; notificationTelegramChatUsername: string; - telegramChatItems: Array; + telegramChatItems: Array< + TelegramChatOption & { value: string; label: string; primaryLabel: string } + >; }) { const hasSelectedTelegramChat = args.notificationTelegramChatId.trim().length > 0 && @@ -405,6 +429,26 @@ function createDialogOpeners(args: { args.setTelegramChatsError(null); args.setIsNotificationDialogOpen(true); }, + openCreateTelegramNotificationDialog() { + args.setEditingNotificationId(null); + args.notificationForm.reset({ + ...createEmptyNotificationValues(), + channel: "telegram", + }); + args.setTelegramChats([]); + args.setTelegramChatsError(null); + args.setIsNotificationDialogOpen(true); + }, + openCreateSlackNotificationDialog() { + args.setEditingNotificationId(null); + args.notificationForm.reset({ + ...createEmptyNotificationValues(), + channel: "slack", + }); + args.setTelegramChats([]); + args.setTelegramChatsError(null); + args.setIsNotificationDialogOpen(true); + }, openCreateCompletionCheckDialog() { args.setEditingCompletionCheckId(null); args.completionCheckForm.reset(createEmptyCompletionCheckValues()); @@ -472,7 +516,11 @@ function createSettingsRouteModelResult(args: { saveHandlers: ReturnType; selectedTelegramChat: ReturnType["selectedTelegramChat"]; settingsForm: ReturnType>; - telegramChatItems: ReturnType>>; + telegramChatItems: ReturnType< + typeof useMemo< + Array + > + >; }) { return { ...args.loopndrollState, @@ -480,9 +528,13 @@ function createSettingsRouteModelResult(args: { completionChecks: args.completionChecks, editingCompletionCheckId: args.dialogState.editingCompletionCheckId, editingNotificationId: args.dialogState.editingNotificationId, - hasResolvedHookState: - !args.loopndrollState.isLoading && args.loopndrollState.snapshot !== null, + hasResolvedHookState: !args.loopndrollState.isLoading && args.loopndrollState.snapshot !== null, + hookLifecycle: args.loopndrollState.snapshot?.hookLifecycle ?? null, + hookIssues: args.loopndrollState.snapshot?.health.issues ?? [], + hookRemovalWatcher: args.loopndrollState.snapshot?.health.hookRemovalWatcher ?? null, hooksDetected: args.loopndrollState.snapshot?.health.registered ?? false, + mirrorEnabled: args.loopndrollState.snapshot?.mirrorEnabled ?? false, + runtimeState: args.loopndrollState.snapshot?.runtimeState ?? "running", isCompletionCheckDialogOpen: args.dialogState.isCompletionCheckDialogOpen, isLoadingTelegramChats: args.dialogState.isLoadingTelegramChats, isNotificationDialogOpen: args.dialogState.isNotificationDialogOpen, @@ -509,30 +561,15 @@ function useTelegramChatSelection(args: { telegramChats: TelegramChatOption[]; }) { const telegramChatItems = useMemo( - () => - args.telegramChats.map((chat) => ({ - ...chat, - value: chat.chatId, - label: - chat.kind === "dm" - ? chat.username - ? `@${chat.username}` - : chat.displayName - : chat.displayName || (chat.username ? `@${chat.username}` : "Unknown chat"), - primaryLabel: - chat.kind === "dm" - ? chat.username - ? `@${chat.username}` - : chat.displayName - : chat.displayName || (chat.username ? `@${chat.username}` : "Unknown chat"), - })), + () => filterTelegramDirectMessageChats(args.telegramChats).map(toTelegramChatItem), [args.telegramChats], ); return { telegramChatItems, selectedTelegramChat: buildSelectedTelegramChat({ - notificationTelegramChatDisplayName: args.notificationState.notificationTelegramChatDisplayName, + notificationTelegramChatDisplayName: + args.notificationState.notificationTelegramChatDisplayName, notificationTelegramChatId: args.notificationState.notificationTelegramChatId, notificationTelegramChatUsername: args.notificationState.notificationTelegramChatUsername, telegramChatItems, diff --git a/src/pages/settings/sections.tsx b/src/pages/settings/sections.tsx index 2406cc2..82f10f2 100644 --- a/src/pages/settings/sections.tsx +++ b/src/pages/settings/sections.tsx @@ -1,4 +1,17 @@ -import { DotsThree, Plus } from "@phosphor-icons/react"; +import { + ArrowClockwise, + CaretDown, + CaretRight, + CheckCircle, + DownloadSimple, + DotsThree, + Info, + Plus, + ShieldCheck, + SlackLogo, + TelegramLogo, +} from "@phosphor-icons/react"; +import { useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -8,6 +21,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -15,31 +29,36 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - Field, - FieldContent, - FieldError, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; +import { Field, FieldContent, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import type { CompletionCheck, LoopNotification } from "@/lib/loopndroll"; +import type { + CompletionCheck, + HookLifecycleStatus, + HookRemovalWatcherStatus, + LoopNotification, +} from "@/lib/loopndroll"; +import type { AppUpdateState } from "@/lib/app-update"; import { getNotificationChannelLabel, + SLACK_WEBHOOK_URL_KEYCHAIN_REF_PREFIX, settingsSectionCardClassName, settingsSectionFooterClassName, + TELEGRAM_BOT_TOKEN_KEYCHAIN_REF_PREFIX, type SettingsFormValues, } from "./common"; export function DefaultPromptSection(props: { defaultPromptError: string | undefined; - form: { register: ReturnType>["register"] }; + form: { + register: ReturnType>["register"]; + }; onSubmit: () => void; }) { return ( - - + + Continue prompt Sent to Codex when completion is blocked, so the task continues instead of stopping. @@ -57,22 +76,192 @@ export function DefaultPromptSection(props: { > - Prompt + + Prompt +