@justfortytwo/telegram 0.1.4 → 1.0.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,66 @@
1
1
  # @justfortytwo/telegram
2
2
 
3
3
  The **Telegram channel adapter** for [fortytwo](https://github.com/justfortytwo) — a
4
- standalone, always-on **bridge** that connects a Telegram bot to a headless
5
- [`claude`](https://docs.claude.com/en/docs/claude-code) turn loop.
6
-
7
- It does three jobs:
8
-
9
- 1. **Turn loop.** Long-poll Telegram, and for each authorized inbound message
10
- drive a headless `claude -p --output-format json` turn (resuming the per-chat
11
- session), then transport the assistant's reply back.
12
- 2. **Approval UX.** When the safety gate defers an external/irreversible tool
13
- call, the turn ends with `stop_reason: tool_deferred`. The bridge surfaces an
14
- **inline-keyboard approval card** (Approve / Deny, plus "Allow _N_h" for exact
15
- Bash commands). The tap records the decision and resumes the turn so the
16
- one-shot allow fires.
17
- 3. **Proactive wakes.** A scheduler invokes `claude -p` at fixed cron-like slots
18
- (daily briefing, open-thread sweeps, weekly learn-review).
4
+ standalone, always-on **bridge** connecting a Telegram bot to persistent Claude
5
+ Code sessions through `@justfortytwo/runner`.
6
+
7
+ 1. **Transport.** Each active authorized chat normally has one live RunnerSession.
8
+ After authorization, attachment ingestion and optional journaling, input waits
9
+ only for `session.send()` to write it. Polling continues while Claude works;
10
+ another message goes to the same session immediately after preprocessing.
11
+ 2. **Output and approvals.** Complete assistant-message events are coalesced into
12
+ an evolving Telegram reply, edited at most once per second during streaming.
13
+ A result finalizes the reply; long replies use additional messages of at most
14
+ 4000 UTF-16 units. Typing refreshes while submitted inputs await results.
15
+ Existing gate deferrals produce approval cards. Approving sends the existing
16
+ continuation prompt to the live session, or recreates it using a saved ID.
17
+ 3. **Scheduler separation.** Proactive wakes remain the responsibility of the
18
+ external `@justfortytwo/scheduler` daemon.
19
+
20
+ ## Session lifecycle and recovery
21
+
22
+ The bridge owns the chat-to-session mapping. Runner owns the Claude process and
23
+ stream-json transport; Claude Code owns the agent. Telegram makes no provider or
24
+ model decisions. The runner inherits process configuration and receives
25
+ `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` explicitly, without changing `process.env`.
26
+ Persistent sessions have no default wall-clock turn timeout.
27
+
28
+ Claude stays alive between messages. When runner exposes a session ID, the bridge
29
+ writes it to `sessions` in `state/bridge-state.json`. Following a bridge restart,
30
+ the next authorized message lazily creates a session with `resumeSessionId`.
31
+ These IDs are recovery hints: the state file is not a transactional message queue,
32
+ and in-flight inputs, partial replies, or unobserved IDs can be lost on a crash.
33
+ Recovery also requires Claude Code's underlying session data to remain available.
34
+
35
+ An unexpected Claude exit removes the live mapping, retains the last known ID,
36
+ and reports an error if input was awaiting a result. The next message attempts
37
+ resumption; there is no automatic restart supervisor or input replay. Stderr is
38
+ logged locally. Asynchronous Telegram delivery failures are logged; there is no
39
+ new durable output queue. Assistant events are complete messages, not token-level
40
+ streaming, and concurrent inputs share the session's output stream.
41
+
42
+ SIGINT/SIGTERM abort polling, stop new session work and deterministically close
43
+ live RunnerSessions. `/logout` closes the chat's live process and removes its
44
+ binding, while retaining durable conversation IDs and existing pending approval
45
+ recovery data. The existing `ALLOWED_CHAT_IDS` bootstrap authorization still applies
46
+ independently of bindings.
47
+
48
+ The runner dependency is pinned to the published prerelease
49
+ `@justfortytwo/runner@1.0.0-0` (npm dist-tag `next`), the first release with
50
+ persistent sessions. Move to a stable `^1` range once runner publishes one.
51
+
52
+ ### Manual check against a real Claude Code
53
+
54
+ The automated suite uses fake runners only. To verify concurrency end to end, run
55
+ the bridge with a test bot (`TELEGRAM_BOT_TOKEN`, `ALLOWED_CHAT_IDS=<your chat>`,
56
+ `claude` on `PATH`), then:
57
+
58
+ 1. Send a message that makes Claude work for a while (e.g. "count slowly to 20
59
+ using a Bash `sleep 1` between numbers").
60
+ 2. While typing is shown, send a second message. The bridge log should print the
61
+ second `inbound` line immediately, and `ps` should show a single `claude` child.
62
+ 3. Stop the bridge with Ctrl-C and confirm no `claude` process remains; restart
63
+ it and send a message: the reply should continue the same conversation.
19
64
 
20
65
  ## Pairing / login binding model
21
66
 
@@ -33,7 +78,7 @@ channel side: /login <code> -> verify(chatId, code) -> bindin
33
78
  bind.
34
79
  - `/login <code>` verifies the code and, on success, **persists a binding**
35
80
  `(channelType, channelUserId) -> owner`.
36
- - `/logout` removes the binding.
81
+ - `/logout` removes the binding and closes the live session (recovery IDs remain).
37
82
  - Every inbound message is authorized against **persisted bindings ∪ the
38
83
  optional `ALLOWED_CHAT_IDS` bootstrap set**. The bootstrap set lets the first
39
84
  owner chat reach the bridge before any binding exists; it is not required once
@@ -64,20 +109,23 @@ sqlite db exists:
64
109
  > table, swap the default store for a memory-backed `BindingStore`. The login
65
110
  > flow is unaffected.
66
111
 
67
- ## Peer dependencies
112
+ ## fortytwo package dependencies
68
113
 
69
- This package orchestrates two **peer** packages — they are referenced, not
70
- bundled:
114
+ `@justfortytwo/gate` is a regular dependency: `attachments.ts` imports its
115
+ `createSourceEnvelope` provenance helper at runtime (gate has no dependencies of
116
+ its own). Using gate as a *policy hook* remains optional and is configured
117
+ separately. `@justfortytwo/memory` is an **optional peer**: it is loaded
118
+ dynamically, and it is also a devDependency so types and tests resolve.
71
119
 
72
- | Peer | Used for | Contract |
120
+ | Package | Used for | Contract |
73
121
  | --- | --- | --- |
74
122
  | `@justfortytwo/memory` | Memory store, jobs, pending-decision records, embeddings | `MEMORY_TOOL_CONTRACT_VERSION` (tools `mcp__fortytwo-memory__*`) |
75
123
  | `@justfortytwo/gate` | Bash exact-command allowlist behind "Allow _N_h"; provenance envelopes | `POLICY_SCHEMA_VERSION` |
76
124
 
77
- Install them alongside this package:
125
+ Install memory alongside this package to enable persistence:
78
126
 
79
127
  ```sh
80
- npm install @justfortytwo/telegram @justfortytwo/memory @justfortytwo/gate
128
+ npm install @justfortytwo/telegram @justfortytwo/memory
81
129
  ```
82
130
 
83
131
  The **memory** data path is wired: the bridge opens + migrates the memory DB at
@@ -85,8 +133,8 @@ startup and **persists each channel event** to `@justfortytwo/memory`; recall
85
133
  happens inside the headless `claude` turn via the Memory MCP tools. If the memory
86
134
  peer is absent, the bridge degrades to **channel-only** (messages relay but are
87
135
  not persisted) rather than refusing to start. The **gate** "Allow _N_h" path
88
- still uses a local bash-allowlist fallback; wiring it to `@justfortytwo/gate`'s
89
- exact-command allowlist is the remaining seam.
136
+ still uses a local placeholder; wiring it to `@justfortytwo/gate`'s
137
+ exact-command allowlist remains a stub, as does the cross-process pending-decision write. This migration preserves those existing seams; it does not implement gate permissions.
90
138
 
91
139
  ## Environment
92
140
 
@@ -95,7 +143,6 @@ exact-command allowlist is the remaining seam.
95
143
  | `TELEGRAM_BOT_TOKEN` | yes | Bot token. The bridge is the **only** long-poller on this bot. |
96
144
  | `ALLOWED_CHAT_IDS` | bootstrap | Comma-separated chat IDs allowed before any binding exists. Optional once bindings exist. |
97
145
  | `CLAUDE_BIN` | no | Path to the `claude` binary (default `claude`). |
98
- | `FORTYTWO_TURN_TIMEOUT` | no | Hard cap (seconds) on a single turn so a stalled model can't wedge the bridge (default 300). |
99
146
  | `FORTYTWO_BASH_ALLOW_TTL_HOURS` | no | Default TTL (hours) for "Allow _N_h" bash approvals (default 8). |
100
147
  | `TELEGRAM_BINDINGS_DB` | no | Path to the self-owned bindings db (default `state/telegram-bindings.db`). |
101
148
  | `DB_PATH` | no | Path to the memory DB the bridge opens/migrates at startup (default `db/fortytwo.db`, relative to `FORTYTWO_ROOT`). |
@@ -104,7 +151,7 @@ exact-command allowlist is the remaining seam.
104
151
  | `ASSISTANT_NAME` / `ASSISTANT_ACTOR` / `OWNER_ACTOR` | no | Display name + journal actor labels. |
105
152
  | `FORTYTWO_ROOT` | no | Working root for the headless `claude` process + state files. |
106
153
 
107
- > The historical `FORD_*` names (`FORD_ROOT`, `FORD_TURN_TIMEOUT`,
154
+ > The historical `FORD_*` names (`FORD_ROOT`,
108
155
  > `FORD_BASH_ALLOW_TTL_HOURS`) are still honored as deprecated aliases — set the
109
156
  > `FORTYTWO_*` equivalents in new deployments.
110
157
 
package/dist/bridge.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { type TgMessage, type AttachmentSpec } from './telegram.js';
2
+ import { ChatSessions } from './chat-sessions.js';
3
+ import { Telegram, type TgMessage, type AttachmentSpec } from './telegram.js';
3
4
  import { type Attachment } from './attachments.js';
5
+ import { type AuthContext } from './login.js';
4
6
  import type { SourceKind } from '@justfortytwo/gate';
5
- import type { MemoryInput } from '@justfortytwo/memory';
7
+ import type { DbHandles as MemDbHandles, Embedder as MemEmbedder, MemoryInput } from '@justfortytwo/memory';
8
+ type DbHandles = MemDbHandles | null;
9
+ type Embedder = MemEmbedder | null;
6
10
  interface ChannelEvent {
7
11
  channel: string;
8
12
  direction: string;
@@ -15,6 +19,16 @@ interface ChannelEvent {
15
19
  }
16
20
  /** Map a channel event onto memory's generic MemoryInput (content + provenance). */
17
21
  export declare function mapChannelEventToMemoryInput(entry: ChannelEvent): MemoryInput;
22
+ /**
23
+ * Load the optional memory engine: open + migrate the DB and build an embedder
24
+ * (Ollama when EMBED_MODEL is set, else a deterministic fake). Returns null
25
+ * handles when memory is not installed or fails to open — the bridge then runs
26
+ * channel-only rather than refusing to start.
27
+ */
28
+ export declare function loadMemoryEngine(dbPath: string): Promise<{
29
+ h: DbHandles;
30
+ embedder: Embedder;
31
+ }>;
18
32
  export interface DeferredTool {
19
33
  id: string;
20
34
  name: string;
@@ -28,13 +42,6 @@ export interface Interpreted {
28
42
  /** Read a `claude -p --output-format json` result into a reply OR a deferred tool. */
29
43
  export declare function interpretResult(r: any): Interpreted;
30
44
  export declare function formatApprovalCard(d: DeferredTool, bashTtlHours?: number): string;
31
- export interface Schedule {
32
- name: string;
33
- minute: number;
34
- hour: number;
35
- dow?: number;
36
- trigger: string;
37
- }
38
45
  export declare function telegramSourceKind(message: TgMessage): SourceKind;
39
46
  export declare function hasHandleableContent(message: TgMessage): boolean;
40
47
  export declare function telegramCommandReply(text: string): string | undefined;
@@ -57,7 +64,19 @@ export declare function ingestMessageAttachments(io: AttachmentIo, message: TgMe
57
64
  attachments: Attachment[];
58
65
  skipped: AttachmentSpec[];
59
66
  }>;
60
- export declare const SCHEDULES: Schedule[];
61
- /** Schedules whose cron slot matches `now` and haven't fired in this slot yet. */
62
- export declare function dueSchedules(now: Date, fired: Record<string, string>): Schedule[];
67
+ export interface BridgeState {
68
+ lastUpdateId: number;
69
+ sessions: Record<number, string>;
70
+ pending: Record<number, {
71
+ toolUseId: string;
72
+ sessionId?: string;
73
+ toolName?: string;
74
+ input?: Record<string, unknown>;
75
+ }>;
76
+ }
77
+ export declare function createBridgeSessions(tg: Telegram, state: BridgeState, h: DbHandles, embedder: Embedder): ChatSessions;
78
+ export declare function handleInbound(tg: Telegram, auth: AuthContext, state: BridgeState, h: DbHandles, embedder: Embedder, chatId: number, message: TgMessage, sessions: ChatSessions): Promise<void>;
79
+ /** Handle an inline-keyboard tap on an approval card. */
80
+ export declare function handleCallback(tg: Telegram, state: BridgeState, h: DbHandles, embedder: Embedder, chatId: number, cbId: string, data: string, cardMessageId: number, sessions: ChatSessions): Promise<void>;
81
+ export declare function pollLoop(tg: Telegram, auth: AuthContext, state: BridgeState, h: DbHandles, embedder: Embedder, sessions: ChatSessions, signal: AbortSignal): Promise<void>;
63
82
  export {};
package/dist/bridge.js CHANGED
@@ -2,12 +2,12 @@
2
2
  // Telegram bridge adapter. Always-on process (tmux / wakeup loop). It does:
3
3
  // 1. Long-poll Telegram, authorize each inbound against persisted bindings ∪
4
4
  // ALLOWED_CHAT_IDS bootstrap (login.ts), journal inbound on receipt, drive a
5
- // headless `claude -p` per chat, and transport the assistant's reply back.
5
+ // persistent RunnerSession per chat, and transport the assistant's reply back.
6
6
  // 2. Approval UX: when the gate defers an [external]/[irreversible] call the
7
7
  // headless turn ends with stop_reason tool_deferred; surface a card with an
8
8
  // inline keyboard (Approve/Deny). The tap records the decision + resumes so
9
9
  // the one-shot allow fires.
10
- // 3. Proactive wakes (scheduler): invoke `claude -p` at the spec's cron times.
10
+ // 3. Proactive wakes are handled by @justfortytwo/scheduler (external daemon).
11
11
  //
12
12
  // CROSS-PACKAGE NOTE: this bridge orchestrates two PEER packages —
13
13
  // - @justfortytwo/memory : memory store, jobs, pending-decision records, embeds.
@@ -15,10 +15,12 @@
15
15
  // Their imports below are STUBBED with `// TODO(wire):` markers. We do not
16
16
  // copy their code; we declare them as peerDependencies and reference the
17
17
  // contract names (MEMORY_TOOL_CONTRACT_VERSION, POLICY_SCHEMA_VERSION).
18
- import { spawn } from 'node:child_process';
19
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from 'node:fs';
20
19
  import { resolve, dirname } from 'node:path';
21
20
  import { fileURLToPath } from 'node:url';
21
+ import { createRunner } from '@justfortytwo/runner';
22
+ import { ChatSessions } from './chat-sessions.js';
23
+ import { enqueueReply } from './notify.js';
22
24
  import { Telegram, parseAllowed, approvalKeyboard, messageAttachmentSpecs } from './telegram.js';
23
25
  import { buildAttachment, inboxRelPath, withinSizeLimit, extensionFor } from './attachments.js';
24
26
  import { loadAdaptersConfig } from './adapters-config.js';
@@ -45,13 +47,16 @@ export function mapChannelEventToMemoryInput(entry) {
45
47
  }
46
48
  // memory.store, captured by loadMemoryEngine when the peer is present.
47
49
  let memStore = null;
50
+ // memory job helpers, captured when the peer is present.
51
+ let memEnqueue = null;
52
+ let memExistsPending = null;
48
53
  /**
49
54
  * Load the optional memory engine: open + migrate the DB and build an embedder
50
55
  * (Ollama when EMBED_MODEL is set, else a deterministic fake). Returns null
51
56
  * handles when memory is not installed or fails to open — the bridge then runs
52
57
  * channel-only rather than refusing to start.
53
58
  */
54
- async function loadMemoryEngine(dbPath) {
59
+ export async function loadMemoryEngine(dbPath) {
55
60
  let mod;
56
61
  try {
57
62
  mod = (await import('@justfortytwo/memory'));
@@ -69,6 +74,8 @@ async function loadMemoryEngine(dbPath) {
69
74
  ? new mod.OllamaEmbedder(embedModel, process.env.OLLAMA_BASE_URL)
70
75
  : new mod.FakeEmbedder();
71
76
  memStore = mod.store;
77
+ memEnqueue = mod.enqueue;
78
+ memExistsPending = mod.existsPending;
72
79
  console.log(`[bridge] memory wired (db=${dbPath}, embedder=${embedModel ? 'ollama:' + embedModel : 'fake'}).`);
73
80
  return { h, embedder };
74
81
  }
@@ -77,16 +84,30 @@ async function loadMemoryEngine(dbPath) {
77
84
  return { h: null, embedder: null };
78
85
  }
79
86
  }
87
+ /**
88
+ * Build an EnqueueReplyDeps bound to the bridge's memory handle.
89
+ * Returns null when memory is not wired (channel-only mode).
90
+ *
91
+ * FLAG: the trigger condition for enqueueReply is best-effort. The bridge
92
+ * does not have a hard "busy/unreachable" state — it's used here in the
93
+ * pollLoop error handler when a message cannot be delivered inline, so the
94
+ * scheduler can drain it later. The dedup + DI is the primary deliverable.
95
+ */
96
+ function makeEnqueueReplyDeps(h) {
97
+ if (!h || !memEnqueue || !memExistsPending)
98
+ return null;
99
+ const boundEnqueue = memEnqueue.bind(null, h);
100
+ const boundExistsPending = memExistsPending.bind(null, h);
101
+ return {
102
+ enqueue: (j) => boundEnqueue(j),
103
+ existsPending: (kind, id) => boundExistsPending(kind, id),
104
+ };
105
+ }
80
106
  async function store(h, e, entry) {
81
107
  if (!h || !e || !memStore)
82
108
  return 0; // channel-only mode
83
109
  return memStore(h, e, mapChannelEventToMemoryInput(entry));
84
110
  }
85
- async function addJob(_h, _item) {
86
- // No-op: the standalone memory server embeds inline on `store`, so there is no
87
- // separate reembed job, and the deferred-jobs runner was left out of memory's scope.
88
- return 0;
89
- }
90
111
  async function setPendingDecisionByToolUseId(_h, _tuid, _status, _by) {
91
112
  // Best-effort no-op: the authoritative one-shot approval lives in the gate's
92
113
  // store and is consumed when the headless turn re-fires the deferred call; the
@@ -220,86 +241,33 @@ function telegramSourceMeta(message) {
220
241
  },
221
242
  };
222
243
  }
223
- export const SCHEDULES = [
224
- { name: 'daily-briefing', minute: 57, hour: 7, trigger: 'Run the daily-briefing skill and send me the rundown for today.' },
225
- { name: 'sweep-afternoon', minute: 3, hour: 13, trigger: 'Open-thread sweep: check pending approvals and threads awaiting a reply; give me a one-line status.' },
226
- { name: 'sweep-evening', minute: 3, hour: 18, trigger: 'Open-thread sweep: check pending approvals and threads awaiting reply; one-line status.' },
227
- { name: 'learn-review', minute: 17, hour: 9, dow: 0, trigger: 'Run the learn-review skill: scan the recent Journal for patterns worth promoting. Propose-only — nothing installed.' },
228
- ];
229
- /** Schedules whose cron slot matches `now` and haven't fired in this slot yet. */
230
- export function dueSchedules(now, fired) {
231
- const slot = (s) => `${s.name}-${now.toISOString().slice(0, 13)}`; // dedupe within the hour
232
- return SCHEDULES.filter((s) => {
233
- if (now.getMinutes() !== s.minute || now.getHours() !== s.hour)
234
- return false;
235
- if (s.dow !== undefined && now.getDay() !== s.dow)
236
- return false;
237
- return fired[slot(s)] !== '1';
238
- });
239
- }
240
244
  function loadState() {
241
245
  if (existsSync(STATE_PATH)) {
242
246
  try {
243
- return { lastUpdateId: 0, sessions: {}, pending: {}, fired: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) };
247
+ return { lastUpdateId: 0, sessions: {}, pending: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) };
244
248
  }
245
249
  catch { /* fall through */ }
246
250
  }
247
- return { lastUpdateId: 0, sessions: {}, pending: {}, fired: {} };
251
+ return { lastUpdateId: 0, sessions: {}, pending: {} };
248
252
  }
249
253
  function saveState(s) {
250
254
  mkdirSync(dirname(STATE_PATH), { recursive: true });
251
255
  writeFileSync(STATE_PATH, JSON.stringify(s, null, 2));
252
256
  }
253
- // --- claude driver ---
254
- function runClaude(prompt, sessionId) {
255
- const args = ['-p', prompt, '--output-format', 'json'];
256
- if (sessionId)
257
- args.push('--resume', sessionId);
258
- // Hard cap so a stalled proxy/model turn can't wedge the bridge. Seconds;
259
- // generous default so legit long turns (orient/briefing) still complete.
260
- const timeoutSec = Number(envCompat(process.env, 'FORTYTWO_TURN_TIMEOUT', 'FORD_TURN_TIMEOUT') ?? 300);
261
- return new Promise((resolveTurn) => {
262
- const child = spawn(CLAUDE_BIN, args, {
263
- cwd: ROOT,
264
- // The assistant's canonical memory is the Memory MCP — skip Claude Code's
265
- // built-in file-memory so context isn't double-booked. Scoped to bridge turns.
266
- env: { ...process.env, CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' },
267
- stdio: ['ignore', 'pipe', 'pipe'],
268
- });
269
- let out = '';
270
- let err = '';
271
- let settled = false;
272
- const finish = (r) => { if (!settled) {
273
- settled = true;
274
- clearTimeout(timer);
275
- resolveTurn(r);
276
- } };
277
- const timer = setTimeout(() => {
278
- child.kill('SIGTERM');
279
- console.error(`[bridge] turn aborted after ${timeoutSec}s (timeout)`);
280
- finish({ result: `⏳ ${ASSISTANT_NAME}'s turn exceeded ${timeoutSec}s and was aborted (the proxy/model may have stalled). Try again.` });
281
- }, timeoutSec * 1000);
282
- child.stdout.on('data', (d) => { out += d; });
283
- child.stderr.on('data', (d) => { err += d; });
284
- child.on('error', (e) => finish({ result: `(claude failed to start: ${e.message})` }));
285
- child.on('close', () => {
286
- try {
287
- finish(JSON.parse(out));
288
- }
289
- catch {
290
- finish({ result: (out || err).slice(0, 4000) || '(empty claude response)' });
291
- }
292
- });
257
+ // --- persistent Claude sessions ---
258
+ export function createBridgeSessions(tg, state, h, embedder) {
259
+ const runner = createRunner({ bin: CLAUDE_BIN, cwd: ROOT, env: { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } });
260
+ return new ChatSessions(runner, tg, {
261
+ ids: state.sessions,
262
+ persist: () => saveState(state),
263
+ onResult: (chatId, result, text) => respond(tg, h, embedder, state, chatId, result, text),
293
264
  });
294
265
  }
295
266
  // --- I/O glue ---
296
267
  async function logBridgeEntry(h, embedder, entry) {
297
- // TODO(wire): @justfortytwo/memory `store` (mcp__fortytwo-memory__store) — the
298
- // generic memory write that replaces the original assistant's old logEntry/log_entry tool.
299
- const id = await store(h, embedder, entry);
300
- // TODO(wire): @justfortytwo/memory job kind 'reembed_memory'.
301
- await addJob(h, { kind: 'reembed_memory', payload: { memory_id: id }, max_attempts: 5 });
302
- return id;
268
+ // memory's store() inline-enqueues a reembed_memory job when needed; no
269
+ // separate addJob call required here (that stub has been removed).
270
+ return store(h, embedder, entry);
303
271
  }
304
272
  async function sendReply(tg, chatId, placeholderId, text) {
305
273
  if (placeholderId !== null && text && text.length <= 4000) {
@@ -311,27 +279,23 @@ async function sendReply(tg, chatId, placeholderId, text) {
311
279
  }
312
280
  await tg.sendMessage(chatId, text || '(no reply)');
313
281
  }
314
- async function respond(tg, h, embedder, state, chatId, placeholderId, result) {
315
- if (result?.session_id)
316
- state.sessions[chatId] = result.session_id;
317
- const interp = interpretResult(result);
318
- if (interp.deferred) {
282
+ async function respond(tg, h, embedder, state, chatId, result, text) {
283
+ // Raw result access is confined to the existing gate compatibility seam.
284
+ const deferred = interpretResult(result.raw).deferred;
285
+ if (deferred) {
319
286
  state.pending[chatId] = {
320
- toolUseId: interp.deferred.id, sessionId: result?.session_id,
321
- toolName: interp.deferred.name, input: interp.deferred.input,
287
+ toolUseId: deferred.id, sessionId: state.sessions[chatId],
288
+ toolName: deferred.name, input: deferred.input,
322
289
  };
323
- console.log(`[bridge] chat ${chatId}: action deferred (${interp.deferred.name}) — approval card sent`);
324
- const keyboardOpts = interp.deferred.name === 'Bash' ? { bashTtlHours: BASH_ALLOW_TTL_HOURS } : {};
325
- await tg.sendMessage(chatId, formatApprovalCard(interp.deferred), approvalKeyboard(interp.deferred.id, keyboardOpts));
326
- }
327
- else {
328
- const text = interp.reply ?? '(no reply)';
329
- await logBridgeEntry(h, embedder, { channel: 'telegram', direction: 'outbound', actor: ASSISTANT_ACTOR, kind: 'message', content: text });
330
- console.log(`[bridge] chat ${chatId}: replied (${text.length} chars)`);
331
- await sendReply(tg, chatId, placeholderId, text);
290
+ saveState(state);
291
+ const keyboardOpts = deferred.name === 'Bash' ? { bashTtlHours: BASH_ALLOW_TTL_HOURS } : {};
292
+ await tg.sendMessage(chatId, formatApprovalCard(deferred), approvalKeyboard(deferred.id, keyboardOpts));
293
+ return true;
332
294
  }
295
+ await logBridgeEntry(h, embedder, { channel: 'telegram', direction: 'outbound', actor: ASSISTANT_ACTOR, kind: 'message', content: text });
296
+ return false;
333
297
  }
334
- async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
298
+ export async function handleInbound(tg, auth, state, h, embedder, chatId, message, sessions) {
335
299
  const text = message.text ?? '';
336
300
  // --- AUTH GATE (lockdown) -------------------------------------------------
337
301
  // Auth commands (/login, /logout) and the unbound-sender drop are handled
@@ -345,6 +309,7 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
345
309
  await sendReply(tg, chatId, null, action.reply);
346
310
  return;
347
311
  case 'logout':
312
+ await sessions.close(chatId);
348
313
  console.log(`[bridge] chat ${chatId}: unbound`);
349
314
  await sendReply(tg, chatId, null, action.reply);
350
315
  return;
@@ -363,7 +328,6 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
363
328
  }
364
329
  }
365
330
  // --- end auth gate --------------------------------------------------------
366
- const t0 = Date.now();
367
331
  const io = {
368
332
  download: async (fileId) => tg.downloadFile(await tg.getFilePath(fileId)),
369
333
  write: (relPath, bytes) => { const abs = resolve(ROOT, relPath); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, bytes); },
@@ -399,14 +363,10 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
399
363
  }
400
364
  const prompt = buildInboundPrompt(text, attachments);
401
365
  console.log(`[bridge] chat ${chatId}: inbound "${journalContent.slice(0, 50)}" (${attachments.length} att) — waking assistant`);
402
- const t1 = Date.now();
403
- const result = await withTyping(tg, chatId, () => runClaude(prompt, state.sessions[chatId]));
404
- const claudeMs = Date.now() - t1;
405
- await respond(tg, h, embedder, state, chatId, null, result);
406
- console.log(`[bridge] chat ${chatId}: turn ${Date.now() - t0}ms (claude ${claudeMs}ms)`);
366
+ await sessions.send(chatId, prompt);
407
367
  }
408
368
  /** Handle an inline-keyboard tap on an approval card. */
409
- async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMessageId) {
369
+ export async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMessageId, sessions) {
410
370
  const sep = data.indexOf(':');
411
371
  const action = sep >= 0 ? data.slice(0, sep) : data;
412
372
  const tuid = sep >= 0 ? data.slice(sep + 1) : '';
@@ -443,8 +403,8 @@ async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMe
443
403
  delete state.pending[chatId];
444
404
  await tg.answerCallbackQuery(cbId, statusText).catch(() => { });
445
405
  await tg.editMessageText(chatId, cardMessageId, `${statusText}...`).catch(() => { });
446
- const result = await withTyping(tg, chatId, () => runClaude('Approved - proceed with the deferred action now.', pending.sessionId));
447
- await respond(tg, h, embedder, state, chatId, null, result);
406
+ saveState(state);
407
+ await sessions.send(chatId, 'Approved - proceed with the deferred action now.', pending.sessionId);
448
408
  }
449
409
  else {
450
410
  console.log(`[bridge] chat ${chatId}: denied ${tuid}`);
@@ -454,18 +414,24 @@ async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMe
454
414
  await tg.editMessageText(chatId, cardMessageId, 'Denied - the action was not run.').catch(() => { });
455
415
  }
456
416
  }
457
- async function pollLoop(tg, auth, state, h, embedder) {
458
- while (true) {
417
+ export async function pollLoop(tg, auth, state, h, embedder, sessions, signal) {
418
+ // Pre-build dedup deps once; null when memory is unavailable (channel-only).
419
+ const replyDeps = makeEnqueueReplyDeps(h);
420
+ while (!signal.aborted) {
459
421
  let updates = [];
460
422
  try {
461
- updates = await tg.getUpdates(state.lastUpdateId + 1);
423
+ updates = await tg.getUpdates(state.lastUpdateId + 1, 30, signal);
462
424
  }
463
425
  catch (e) {
426
+ if (signal.aborted)
427
+ break;
464
428
  console.error(`[bridge] getUpdates error: ${e?.message}; retrying in 5s`);
465
429
  await sleep(5000);
466
430
  continue;
467
431
  }
468
432
  for (const u of updates) {
433
+ if (signal.aborted)
434
+ break;
469
435
  state.lastUpdateId = Math.max(state.lastUpdateId, u.update_id);
470
436
  const chatId = u.message?.chat?.id ?? u.callback_query?.message?.chat?.id;
471
437
  if (chatId === undefined)
@@ -475,60 +441,44 @@ async function pollLoop(tg, auth, state, h, embedder) {
475
441
  // chat has no pending card, but we still gate to be safe.
476
442
  try {
477
443
  if (u.message && hasHandleableContent(u.message)) {
478
- await handleInbound(tg, auth, state, h, embedder, chatId, u.message);
444
+ await handleInbound(tg, auth, state, h, embedder, chatId, u.message, sessions);
479
445
  }
480
446
  else if (u.callback_query?.data && u.callback_query.message) {
481
447
  if (!isAuthorized(auth, chatId)) {
482
448
  console.log(`[bridge] chat ${chatId}: callback dropped (unbound, lockdown)`);
483
449
  continue;
484
450
  }
485
- await handleCallback(tg, state, h, embedder, chatId, u.callback_query.id, u.callback_query.data, u.callback_query.message.message_id);
451
+ await handleCallback(tg, state, h, embedder, chatId, u.callback_query.id, u.callback_query.data, u.callback_query.message.message_id, sessions);
486
452
  }
487
453
  }
488
454
  catch (e) {
455
+ if (signal.aborted)
456
+ break;
489
457
  console.error(`[bridge] handler error: ${e?.message}`);
490
458
  if (isAuthorized(auth, chatId)) {
459
+ const errMsg = `⚠️ Something went wrong handling that: ${e?.message ?? e}`;
460
+ // Best-effort inline reply; on failure, enqueue via scheduler so the
461
+ // reply is delivered when the bridge recovers (dedup guards against
462
+ // double-delivery if the inline send succeeds on retry).
463
+ // FLAG: this is a conservative wiring — the "unreachable" trigger is
464
+ // a handler exception, not a true busy/offline state. The dedup
465
+ // helper + its tests are the primary deliverable of Part C.
466
+ let inlineSent = false;
491
467
  try {
492
- await tg.sendMessage(chatId, `⚠️ Something went wrong handling that: ${e?.message ?? e}`);
468
+ await tg.sendMessage(chatId, errMsg);
469
+ inlineSent = true;
493
470
  }
494
471
  catch { /* ignore */ }
472
+ if (!inlineSent && replyDeps) {
473
+ await enqueueReply(replyDeps, chatId, errMsg, new Date().toISOString());
474
+ }
495
475
  }
496
476
  }
497
477
  saveState(state);
498
478
  }
499
479
  }
500
480
  }
501
- async function schedulerLoop(tg, state, h, embedder, ownerChatId) {
502
- while (true) {
503
- const now = new Date();
504
- for (const s of dueSchedules(now, state.fired)) {
505
- state.fired[`${s.name}-${now.toISOString().slice(0, 13)}`] = '1';
506
- saveState(state);
507
- console.log(`[bridge] scheduled wake: ${s.name}`);
508
- try {
509
- await logBridgeEntry(h, embedder, { channel: 'internal', direction: 'internal', actor: ASSISTANT_ACTOR, kind: 'wake', content: `scheduled: ${s.name}` });
510
- const result = await runClaude(s.trigger);
511
- await respond(tg, h, embedder, state, ownerChatId, null, result);
512
- }
513
- catch (e) {
514
- console.error(`[bridge] schedule ${s.name} failed: ${e?.message}`);
515
- }
516
- }
517
- await sleep(60_000);
518
- }
519
- }
520
481
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
521
- /** Shows Telegram's native "typing…" indicator (refreshed every 4s) while `fn` runs. */
522
- async function withTyping(tg, chatId, fn) {
523
- await tg.sendChatAction(chatId, 'typing').catch(() => { });
524
- const iv = setInterval(() => tg.sendChatAction(chatId, 'typing').catch(() => { }), 4000);
525
- try {
526
- return await fn();
527
- }
528
- finally {
529
- clearInterval(iv);
530
- }
531
- }
532
482
  // --- main ---
533
483
  async function main() {
534
484
  const token = process.env.TELEGRAM_BOT_TOKEN;
@@ -550,16 +500,27 @@ async function main() {
550
500
  console.error('[bridge] no ALLOWED_CHAT_IDS bootstrap and no existing bindings — nobody can reach the bridge. Set ALLOWED_CHAT_IDS or pre-seed a binding.');
551
501
  process.exit(1);
552
502
  }
553
- const ownerChatId = bootstrap.size ? [...bootstrap][0] : Number(store.list('telegram')[0].channelUserId);
554
503
  // Persist channel events to the memory store (optional peer; channel-only if absent).
555
504
  const dbPath = process.env.DB_PATH ? resolve(ROOT, process.env.DB_PATH) : resolve(ROOT, 'db', 'fortytwo.db');
556
505
  const { h, embedder } = await loadMemoryEngine(dbPath);
557
506
  const tg = new Telegram(token, bootstrap);
558
507
  const state = loadState();
559
- console.log(`[bridge] up. owner chat=${ownerChatId}, bootstrap=${bootstrap.size}, bindings=${store.list('telegram').length}, bindingsDb=${BINDINGS_DB_PATH}`);
560
- // Proactive wakes + polling run concurrently. (jobLoop runs once memory is wired.)
561
- void schedulerLoop(tg, state, h, embedder, ownerChatId);
562
- await pollLoop(tg, auth, state, h, embedder);
508
+ console.log(`[bridge] up. bootstrap=${bootstrap.size}, bindings=${store.list('telegram').length}, bindingsDb=${BINDINGS_DB_PATH}`);
509
+ // Proactive wakes are now handled by @justfortytwo/scheduler (external daemon).
510
+ const sessions = createBridgeSessions(tg, state, h, embedder);
511
+ const controller = new AbortController();
512
+ const shutdown = () => { controller.abort(); void sessions.closeAll(); };
513
+ process.once('SIGINT', shutdown);
514
+ process.once('SIGTERM', shutdown);
515
+ try {
516
+ await pollLoop(tg, auth, state, h, embedder, sessions, controller.signal);
517
+ }
518
+ finally {
519
+ controller.abort();
520
+ await sessions.closeAll();
521
+ process.off('SIGINT', shutdown);
522
+ process.off('SIGTERM', shutdown);
523
+ }
563
524
  }
564
525
  // Run only when invoked directly (the bin / node dist/bridge.js), not when
565
526
  // imported by tests. Realpath comparison so the npm bin symlink resolves here.
@@ -574,6 +535,11 @@ function invokedAsBin() {
574
535
  return false;
575
536
  }
576
537
  }
577
- if (invokedAsBin())
578
- main();
538
+ // main() resolves only after polling stopped and closeAll() awaited every Claude
539
+ // process and queued delivery. Exit naturally so piped stdout/stderr can flush; the
540
+ // unref'd timer forces exit only if an unrelated handle would keep the process up.
541
+ if (invokedAsBin()) {
542
+ main().then(() => { process.exitCode = 0; }, (e) => { console.error('[bridge] fatal:', e); process.exitCode = 1; })
543
+ .finally(() => { setTimeout(() => process.exit(), 2000).unref(); });
544
+ }
579
545
  //# sourceMappingURL=bridge.js.map