Skip to content

Commit 6cbf841

Browse files
bluedbirdclaude
andcommitted
feat: replace task queue with interrupt-first request model
Replace the task-based queue (TaskRunner, TaskStore, UsageStore) with an interrupt-first RequestRunner. New messages now abort the active request and start immediately instead of queuing. Simplify sessions to use an active_sessions pointer table, remove userId from events, and add first-user lock in the Telegram adapter. Fix Codex provider retrying after abort by checking the signal before entering the resume-failure retry path. Fix non-cancellable 3s crash retry sleep in Claude Code provider. Remove duplicate line in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fc34a8 commit 6cbf841

45 files changed

Lines changed: 720 additions & 2238 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,17 @@ bun run format # Biome format
1616

1717
## Architecture
1818

19-
Homie is an async Telegram agent that wraps the local `claude` CLI. Users send messages via Telegram, Homie routes them through a gateway to Claude Code, and returns results. Every message becomes a task — there is no casual chat mode.
19+
Homie is an interrupt-first Telegram agent that wraps local coding CLIs like Codex and Claude Code. Users send messages via Telegram, Homie routes them through a gateway to the local provider, and returns results. The latest message always wins for a chat.
2020

2121
### Data flow
2222

2323
```
2424
Telegram message → TelegramAdapter → Gateway.handleEvent()
25-
├─ Command (/list, /status, etc.) → CommandHandler → reply
26-
└─ Chat message → TaskRunner.submit() (queued, background)
27-
→ SessionManager (history)
28-
→ Agent.run() → buildMessages() → ClaudeCodeProvider.generate()
29-
→ spawns `claude` CLI with stream-json output
30-
→ parse response, save usage
25+
├─ Command (/status, /abort, /clear, /help) → CommandHandler → reply
26+
└─ Chat message → RequestRunner.submit() (interrupts active request)
27+
→ SessionStore (active session + history)
28+
→ Agent.run() → buildMessages() → provider.generate()
29+
→ spawns provider CLI and parses streamed output
3130
→ reply to user
3231
```
3332

@@ -37,21 +36,21 @@ Telegram message → TelegramAdapter → Gateway.handleEvent()
3736
core (types, interfaces, errors) ← everything depends on this
3837
config (YAML loader) ← src/
3938
observability (logger) ← most packages
40-
persistence (SQLite stores) ← sessions, gateway, src/
41-
sessions (session manager) ← gateway, src/
39+
persistence (SQLite stores) ← gateway, src/
4240
providers (claude CLI wrapper) ← agent, src/
4341
agent (context + provider orchestration) ← gateway, src/
44-
gateway (routing, commands, task-runner) ← src/, telegram
42+
gateway (routing, commands, request-runner) ← src/, telegram
4543
channels/telegram (grammy adapter) ← src/
4644
```
4745

4846
### Key patterns
4947

50-
- **No classes.** All modules use factory functions returning interfaces (e.g., `createSessionManager(store): SessionManager`).
48+
- **No classes.** All modules use factory functions returning interfaces.
5149
- **No build step.** Bun resolves `.ts` workspace imports directly via `tsconfig.json` path aliases (`@homie/core``./packages/core/src`).
5250
- **SQLite via `bun:sqlite`** with WAL mode. Inline migrations run on `openDatabase()`. Stores are synchronous under the hood but expose async interfaces.
53-
- **Task queue.** One task runs at a time per chat. New messages queue up (max 10). Sessions are hidden — one per chat, used internally for Claude CLI `--session-id` continuity.
54-
- **Provider resilience:** Session resume via `--resume`, fallback to full history replay, 1 crash retry with 3s backoff.
51+
- **Interrupt-first requests.** One request runs at a time per chat. A new message aborts the old request and starts immediately.
52+
- **Sessions.** Chats can have multiple stored sessions, but only one active session at a time. `/clear` creates a new active session without deleting old history.
53+
- **Telegram ownership.** The first Telegram user to message the bot becomes the only allowed user until restart.
5554
- **Preflight checks:** Server startup validates Telegram bot token (`getMe` API) and Claude Code auth (minimal prompt) in parallel before booting.
5655

5756
### Conventions

README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ No API keys. No cloud billing. Just your existing CLI subscriptions.
1313
- **Remote control** — Telegram is your interface, your machine does the work
1414
- **Local agents** — wraps CLI agents you already have (Claude Code today, more coming)
1515
- **Zero API cost** — rides your existing subscriptions, no billing keys needed
16-
- **Async by design** — fire and forget, check results when you're ready
1716
- **Self-hosted** — your machine, your data, your agents
1817

1918
## Install
@@ -38,16 +37,16 @@ homie
3837

3938
Requires [Bun](https://bun.sh) and a supported local agent CLI installed and authenticated. Homie verifies your Telegram token and the configured provider on startup before accepting messages.
4039

41-
## Every message is a task
40+
## Every message is a priority request
4241

43-
Every message you send becomes a task. Homie runs it and replies with the result.
42+
Every message you send starts a request. Homie runs it and replies with the result. Send a photo or file and Homie downloads it to a temp path so the agent can read it natively.
4443

45-
If Homie is already working on something, your message gets queued and runs next — up to 10 deep. Send a photo or file and Homie downloads it to a temp path so the agent can read it natively.
44+
The first Telegram user to message the bot becomes the only allowed user until Homie restarts.
4645

4746
```
48-
/list Recent tasks and their status
49-
/status Running task, queue, and uptime
50-
/abort Cancel the running task and clear the queue
47+
/status Current request
48+
/abort Interrupt the active request
49+
/clear Start a new session
5150
/help Show commands
5251
```
5352

@@ -56,20 +55,22 @@ If Homie is already working on something, your message gets queued and runs next
5655
```
5756
Telegram message
5857
→ Adapter (parse text, photos, documents)
59-
→ Gateway (resolve session, route command or submit task)
60-
Task runner (queue, execute one at a time per chat)
58+
→ Gateway (resolve active session, route command or submit request)
59+
Request runner (interrupt previous request, execute latest request per chat)
6160
→ Agent (build message history, call provider)
6261
→ Provider CLI (spawn subprocess, stream JSON/JSONL)
6362
→ parse response → reply to user
6463
```
6564

66-
**Provider.** Homie wraps local agent CLIs behind a shared runtime contract. Claude Code uses `--output-format stream-json`; Codex uses `exec --json`. Homie parses streamed events, token usage, and final output, then forwards progress and replies back to Telegram.
65+
**Provider.** Homie wraps local agent CLIs behind a shared runtime contract. Claude Code uses `--output-format stream-json`; Codex uses `exec --json`. Homie parses streamed events and final output, then forwards progress and replies back to Telegram.
6766

68-
**Session continuity.** Each chat gets one hidden session. On the first task, Homie sends the full conversation history. On subsequent tasks, it attempts the provider's native resume flow. If resume fails, it falls back to replaying full history in a fresh run. Claude retries once after a crash; Codex currently fails fast.
67+
**Session continuity.** Each chat has one active session. On the first request in a session, Homie sends the full conversation history. On subsequent requests, it attempts the provider's native resume flow. If resume fails, it falls back to replaying full history in a fresh request. Claude retries once after a crash; Codex currently fails fast.
6968

70-
**Queue.** One task runs at a time per chat. The rest sit in an in-memory queue (also tracked in SQLite so status survives restarts). When a task finishes, the next one starts automatically. Aborting kills the running task and clears the entire queue.
69+
**Interrupt-first flow.** One request is active per chat. A new message interrupts the active request and starts a fresh request immediately. Interrupted partial output is not added to conversation history.
7170

72-
**Progress.** While a task runs, Homie sends typing indicators every 4 seconds and a status message every 30 seconds showing elapsed time and what the agent is doing ("Reading files...", "Editing code...", "Running commands...").
71+
**Fresh context.** `/clear` starts a brand new session for the chat without deleting old history. The next message runs from empty context.
72+
73+
**Progress.** While a request is active, Homie sends typing indicators every 4 seconds and a status message every 30 seconds showing elapsed time and what the agent is doing ("Reading files...", "Editing code...", "Running commands...").
7374

7475
## Configure
7576

@@ -78,7 +79,6 @@ Telegram message
7879
| Setting | Default | What it does |
7980
|---------|---------|-------------|
8081
| `telegram.botToken` || `TELEGRAM_BOT_TOKEN` env var (required) |
81-
| `telegram.allowedChatIds` | `[]` | Restrict to specific chats (empty = allow all) |
8282
| `provider.kind` | `claude-code` | Which CLI backend to use: `claude-code` or `codex` |
8383
| `provider.model` | `""` | Override the provider's default model; leave empty to follow the CLI default |
8484
| `provider.extraArgs` | `[]` | Extra CLI flags passed to the agent |
@@ -97,14 +97,13 @@ Recommended `provider.model`:
9797
src/ Boot, preflight checks, wiring, graceful shutdown
9898
bin/ CLI entry point (homie)
9999
packages/
100-
core/ Types (Task, Session, Message), interfaces, errors
100+
core/ Types (Session, Message), interfaces, errors
101101
config/ YAML loader with ${ENV_VAR} interpolation (zod)
102102
observability/ Structured JSON logger
103-
persistence/ SQLite stores — tasks, sessions, messages, usage, kv
104-
sessions/ Session manager — one session per chat, history, status
103+
persistence/ SQLite stores — sessions, active sessions, messages
105104
providers/ Provider runtimes for Claude Code and Codex CLI
106105
agent/ Context builder — system prompt + history → provider messages
107-
gateway/ Task runner (queue + execute), command handler
106+
gateway/ Request runner (interrupt + execute), command handler
108107
channels/
109108
telegram/ Grammy adapter — polling, photos, documents, markdown
110109
```

config/system.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ app:
55
# Telegram (required)
66
telegram:
77
botToken: ${TELEGRAM_BOT_TOKEN}
8-
allowedChatIds: []
98

109
# Claude Code provider
1110
provider:

packages/agent/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Orchestrates provider calls with context assembly. Provider-agnostic — works w
66

77
1. Builds a message list from system prompt, history, and user input (`buildMessages`)
88
2. Sends to the provider (any local agent CLI) and streams progress
9-
3. Returns text, usage stats, and resume status
9+
3. Returns text and resume status
1010

1111
## Usage
1212

@@ -23,5 +23,5 @@ const result = await agent.run({
2323
signal: controller.signal,
2424
});
2525

26-
// result.text, result.usage
26+
// result.text
2727
```

packages/agent/src/agent.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import type {
2-
Message,
3-
ProgressCallback,
4-
ProviderAdapter,
5-
ProviderMessage,
6-
UsageStats,
7-
} from '@homie/core';
1+
import type { Message, ProgressCallback, ProviderAdapter, ProviderMessage } from '@homie/core';
82
import { createLogger } from '@homie/observability';
93

104
const log = createLogger('agent');
@@ -17,15 +11,13 @@ export interface AgentInput {
1711
history: Message[];
1812
/** When true, skip resume and send full history (e.g. after an interrupted run) */
1913
forceFullHistory?: boolean;
20-
userId?: string;
2114
onProgress?: ProgressCallback;
2215
/** Signal to abort the in-flight agent run */
2316
signal?: AbortSignal;
2417
}
2518

2619
export interface AgentOutput {
2720
text: string;
28-
usage?: UsageStats;
2921
/** False when session resume failed and full history was re-sent */
3022
resumed?: boolean;
3123
}
@@ -58,12 +50,10 @@ export function createAgent(provider: ProviderAdapter, config: AgentConfig): Age
5850

5951
log.info('Agent run completed', {
6052
sessionId: input.sessionId,
61-
costUsd: response.usage?.costUsd,
6253
});
6354

6455
return {
6556
text,
66-
usage: response.usage,
6757
resumed: response.resumed,
6858
};
6959
},

packages/channels/telegram/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Telegram channel adapter using [grammy](https://grammy.dev) in polling mode. Fir
77
- Text messages, photos, and documents with automatic file download
88
- Markdown to Telegram MarkdownV2 conversion with fallback to plain text
99
- Typing indicators and editable status messages for progress
10-
- Chat ID allowlisting (empty list = allow all)
10+
- First-user lock in memory for simple single-user access control
1111
- Bot command registration on startup
1212

1313
## Usage
@@ -17,7 +17,6 @@ import { createTelegramAdapter } from '@homie/telegram';
1717

1818
const telegram = createTelegramAdapter({
1919
botToken: process.env.TELEGRAM_BOT_TOKEN,
20-
allowedChatIds: [],
2120
onEvent: gateway.handleEvent,
2221
dataDir: './data',
2322
});

packages/channels/telegram/src/adapter.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,13 @@ function mimeFromPath(filePath: string): string {
102102

103103
export function createTelegramAdapter(opts: {
104104
botToken: string;
105-
allowedChatIds: Array<string | number>;
106105
onEvent: EventHandler;
107106
dataDir: string;
108107
}): ChannelAdapter {
109108
const bot = new Bot(opts.botToken);
110109
const handler = opts.onEvent;
111-
const allowedChatIds = new Set(opts.allowedChatIds.map(String));
112110
const attachDir = join(opts.dataDir, 'attachments');
111+
let authorizedUserId: string | null = null;
113112
mkdirSync(attachDir, { recursive: true });
114113

115114
// --- Internal helpers (not exposed on the public interface) ---
@@ -206,8 +205,24 @@ export function createTelegramAdapter(opts: {
206205

207206
// --- Shared message dispatch ---
208207

209-
function isAllowed(chatId: string): boolean {
210-
return allowedChatIds.size === 0 || allowedChatIds.has(chatId);
208+
function authorizeUser(userId: string | null, chatId: string): boolean {
209+
if (!userId) {
210+
log.warn('Rejected Telegram update without sender', { chatId });
211+
return false;
212+
}
213+
214+
if (!authorizedUserId) {
215+
authorizedUserId = userId;
216+
log.info('Authorized first Telegram user', { chatId, userId });
217+
return true;
218+
}
219+
220+
if (authorizedUserId !== userId) {
221+
log.warn('Rejected message from unauthorized Telegram user', { chatId, userId });
222+
return false;
223+
}
224+
225+
return true;
211226
}
212227

213228
function buildReplyAndProgress(chatId: string) {
@@ -231,8 +246,7 @@ export function createTelegramAdapter(opts: {
231246
const text = ctx.message.text;
232247
const messageId = String(ctx.message.message_id);
233248

234-
if (!isAllowed(chatId)) {
235-
log.warn('Rejected message from unauthorized chat', { chatId });
249+
if (!authorizeUser(userId, chatId)) {
236250
return;
237251
}
238252

@@ -245,7 +259,6 @@ export function createTelegramAdapter(opts: {
245259
type: 'command',
246260
channel: 'telegram',
247261
chatId,
248-
userId,
249262
command: cmdMatch[1] ?? '',
250263
args: (cmdMatch[2] ?? '').trim(),
251264
rawSourceId: messageId,
@@ -257,7 +270,7 @@ export function createTelegramAdapter(opts: {
257270
}
258271

259272
await dispatch(
260-
{ type: 'chat', channel: 'telegram', chatId, userId, text, rawSourceId: messageId },
273+
{ type: 'chat', channel: 'telegram', chatId, text, rawSourceId: messageId },
261274
chatId,
262275
);
263276
});
@@ -268,7 +281,7 @@ export function createTelegramAdapter(opts: {
268281
const messageId = String(ctx.message.message_id);
269282
const caption = ctx.message.caption ?? '';
270283

271-
if (!isAllowed(chatId)) return;
284+
if (!authorizeUser(userId, chatId)) return;
272285

273286
// Telegram sends multiple sizes — pick the largest
274287
const photos = ctx.message.photo;
@@ -286,7 +299,6 @@ export function createTelegramAdapter(opts: {
286299
type: 'chat',
287300
channel: 'telegram',
288301
chatId,
289-
userId,
290302
text: caption || 'Analyze this image',
291303
rawSourceId: messageId,
292304
attachments: [attachment],
@@ -302,7 +314,7 @@ export function createTelegramAdapter(opts: {
302314
const caption = ctx.message.caption ?? '';
303315
const doc = ctx.message.document;
304316

305-
if (!isAllowed(chatId)) return;
317+
if (!authorizeUser(userId, chatId)) return;
306318

307319
const attachment = await downloadFile(doc.file_id, doc.file_name ?? undefined);
308320
if (!attachment) {
@@ -315,7 +327,6 @@ export function createTelegramAdapter(opts: {
315327
type: 'chat',
316328
channel: 'telegram',
317329
chatId,
318-
userId,
319330
text: caption || `Review this file: ${doc.file_name ?? 'document'}`,
320331
rawSourceId: messageId,
321332
attachments: [attachment],
@@ -336,9 +347,9 @@ export function createTelegramAdapter(opts: {
336347

337348
try {
338349
await bot.api.setMyCommands([
339-
{ command: 'list', description: 'Recent tasks' },
340-
{ command: 'status', description: 'System status & running task' },
341-
{ command: 'abort', description: 'Cancel running task' },
350+
{ command: 'status', description: 'Current request status' },
351+
{ command: 'abort', description: 'Interrupt active request' },
352+
{ command: 'clear', description: 'Start a new session' },
342353
{ command: 'help', description: 'Show help' },
343354
]);
344355
} catch (err) {

packages/config/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ YAML configuration loader with environment variable interpolation and zod valida
1414
| Section | Key fields |
1515
| ---------- | --------------------------------------- |
1616
| `app` | `logLevel`, `dataDir` |
17-
| `telegram` | `botToken`, `allowedChatIds` |
17+
| `telegram` | `botToken` |
1818
| `provider` | `kind`, `model`, `extraArgs` |
1919

2020
## Usage

packages/config/src/schema.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export const AppConfigSchema = z.object({
77
}),
88
telegram: z.object({
99
botToken: z.string().min(1, 'TELEGRAM_BOT_TOKEN is required'),
10-
allowedChatIds: z.array(z.union([z.string(), z.number()])).default([]),
1110
}),
1211
provider: z.object({
1312
kind: z.enum(['claude-code', 'codex']).default('claude-code'),

packages/core/README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
Shared types, interfaces, and error classes used across all Homie packages.
44

5-
Defines the contracts between channels, providers, and persistence — any local agent CLI or messaging platform plugs in through these interfaces.
5+
Defines the contracts between channels, providers, and persistence.
66

77
## Exports
88

99
### Types
1010

11-
- `Session`, `Message`, `Task`, `UsageStats` — domain models
12-
- `SessionStatus`, `MessageDirection`, `TaskStatus` — union types
11+
- `Session`, `Message` — domain models
12+
- `MessageDirection` — union types
1313
- `Attachment` — file attachment metadata
1414
- `InboundEvent`, `ChatMessageEvent`, `CommandEvent` — inbound event types
1515

@@ -18,7 +18,6 @@ Defines the contracts between channels, providers, and persistence — any local
1818
- `ChannelAdapter` — start/stop/sendMessage for a messaging platform
1919
- `ProviderAdapter` — generate responses from a local agent CLI
2020
- `SessionStore` — persistence interface for sessions and messages
21-
- `TaskStore` — persistence interface for task queue and history
2221
- `EventHandler`, `ReplyFn`, `ProgressHandler`, `ProgressCallback` — callback types
2322
- `ProviderRequest`, `ProviderResponse` — provider I/O
2423

@@ -31,6 +30,6 @@ Defines the contracts between channels, providers, and persistence — any local
3130
## Usage
3231

3332
```ts
34-
import type { Session, Task, ChannelAdapter, ProviderAdapter } from '@homie/core';
33+
import type { Session, Message, ChannelAdapter, ProviderAdapter } from '@homie/core';
3534
import { ProviderError, getErrorMessage } from '@homie/core';
3635
```

0 commit comments

Comments
 (0)