-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
2214 lines (2093 loc) · 106 KB
/
Copy pathserver.ts
File metadata and controls
2214 lines (2093 loc) · 106 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bun
/**
* Telegram bridge for the opencode CLI (DIVE-11).
*
* UNLIKE the codex/grok/agy forks, opencode is NOT driven through a
* wait_for_message MCP loop. opencode ships a headless HTTP server
* (`opencode serve`) with a 131-route REST API and a `GET /event` SSE push
* stream, so this is a long-running RELAY, not an MCP server:
*
* Telegram inbound ──▶ POST /session/{id}/message ──▶ assistant reply ──▶ Telegram
* GET /event (SSE) ──▶ permission.asked / question.asked
* ──▶ Telegram inline buttons ──▶ reply
* ──▶ session.error ──▶ Telegram
*
* Because the server pushes events, there is NO re-arm watchdog (server.heartbeat
* is the liveness signal), NO Stop/silence hooks (session.idle marks turn-end),
* and NO file-IPC permission bridge (permission.asked/permission reply are API).
* See plugins/telegram-opencode-SPIKE.md for the feasibility findings.
*
* State: ~/.opencode/channels/telegram/{access.json, .env, sessions.json, inbox/, bot.pid}
* Reused verbatim from the grok fork: access control, pairing, chunking, and the
* /status /stop /restart /agents /tasks /task /org /model command handlers.
*/
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context } from 'grammy'
import type { ReactionTypeEmoji } from 'grammy/types'
import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvidenceArgs , yesNoChoice, describeTapError, tapLanding, tapFailureCopy, type TapLanding} from './tna'
// DIVE-2846: aliased so the durable tap-failure record needs no surgery on
// (or collision with) whatever each plugin already imports from 'fs'.
import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs'
import { readAccessFile as readAccessFileCore } from './access-core.ts'
import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner'
import {
readFileSync, writeFileSync, mkdirSync, chmodSync, statSync,
realpathSync, renameSync, existsSync, unlinkSync,
} from 'fs'
import { randomBytes } from 'crypto'
import { homedir } from 'os'
import { join, sep } from 'path'
import { installLifecycle } from './lifecycle.ts'
import { protectTelegramViewerLinks } from './viewer-link.ts'
const PLUGIN_VERSION = (() => {
try {
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
return String(pkg.version ?? 'unknown')
} catch { return 'unknown' }
})()
const STATE_DIR = process.env.TELEGRAM_STATE_DIR
?? join(process.env.OPENCODE_HOME ?? join(homedir(), '.opencode'), 'channels', 'telegram')
// DIVE-2846: a tap that fails must leave a record that outlives the process.
// stderr is captured by the harness that spawns us (measured: "Server stderr:
// telegram channel: …" rows in Claude Code's MCP log), but that log is per
// session and the tmux-hosted forks have only scrollback — which is why the two
// taps that failed on 2026-08-06 could never be diagnosed. So also append a
// bounded JSONL at a fixed path anyone can grep after the fact. Best-effort by
// construction: a record we cannot write must never take the tap handler down.
const TAP_FAIL_LOG = join(STATE_DIR, 'tap-failures.jsonl')
const TAP_FAIL_MAX_BYTES = 256_000
function recordTapFailure(line: string): void {
process.stderr.write(`telegram tna: ${line}\n`)
try {
tapMkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
if (tapStatSync(TAP_FAIL_LOG).size > TAP_FAIL_MAX_BYTES) tapRenameSync(TAP_FAIL_LOG, `${TAP_FAIL_LOG}.1`)
} catch {
// no log yet (or unstatable) — nothing to rotate
}
tapAppendFileSync(TAP_FAIL_LOG, JSON.stringify({ ts: new Date().toISOString(), event: 'tap_failed', line }) + '\n', { mode: 0o600 })
} catch {
// The stderr line above is the floor; never throw out of the tap handler.
}
}
const ACCESS_FILE = join(STATE_DIR, 'access.json')
// DIVE-1503/1558: per-DM pinned "needs-you" banner bookkeeping. Maps a paired DM
// chat id → { messageId, fingerprint } so each reconcile edits the existing pin
// instead of posting a fresh banner (the DIVE-1107 banner-storm lesson).
const NEEDS_BANNER_FILE = join(STATE_DIR, 'needs-banner.json')
const ENV_FILE = join(STATE_DIR, '.env')
const INBOX_DIR = join(STATE_DIR, 'inbox')
const PID_FILE = join(STATE_DIR, 'bot.pid')
// chat_id -> opencode sessionID, so a chat keeps one continuous conversation.
const SESSIONS_FILE = join(STATE_DIR, 'sessions.json')
// Persisted model override (set via /model), "providerID/modelID".
const MODEL_FILE = join(STATE_DIR, 'model')
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
mkdirSync(INBOX_DIR, { recursive: true, mode: 0o700 })
// Lock the token to owner-only, then load it. Real env wins.
try {
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
}
} catch {}
const TOKEN = process.env.TELEGRAM_BOT_TOKEN
// DIVE-1087 team-bot: a member of the shared team bot runs SEND-ONLY against the
// shared token — it MUST NOT poll getUpdates (Telegram allows one consumer per
// token; a 2nd poller = 409 = the listener goes deaf and inline approval taps are
// silently lost fleet-wide). The single team-bot listener is the sole poller; the
// MCP send tools stay live. Opt-in via TELEGRAM_SEND_ONLY=1 in the bridge .env;
// unset = unchanged per-agent polling.
const SEND_ONLY = process.env.TELEGRAM_SEND_ONLY === '1'
if (!TOKEN) {
process.stderr.write(
`telegram-opencode: TELEGRAM_BOT_TOKEN required\n` +
` set in ${ENV_FILE}\n` +
` format: TELEGRAM_BOT_TOKEN=123456789:AAH...\n`,
)
process.exit(1)
}
// Liveness beacon for the single getUpdates slot (DIVE-818/DIVE-819, ported to
// opencode per DIVE-1241). The active poller bumps this file's mtime every
// HEARTBEAT_MS; a newcomer treats the slot as HELD only while the beacon is
// fresh. Acquisition happens in the poll bootstrap at the bottom of the file.
const HEARTBEAT_FILE = join(STATE_DIR, 'bot.heartbeat')
const HEARTBEAT_MS = 3000
// 3 missed beats — how long a newcomer waits before deciding the incumbent died.
const HEARTBEAT_STALE_MS = 9000
// DIVE-818: a TRANSIENT spawn running this server.ts (an overlapping respawn, or
// a shared-checkout enumeration) used to eagerly SIGTERM whatever PID held the
// slot and claim it, then die — leaving NO poller (channel deaf, relay gone
// until a manual restart). Fix: never stomp a HEALTHY incumbent; acquisition is
// deferred to the poll bootstrap, which waits out a fresh heartbeat and only
// reclaims the slot once the beacon goes stale (incumbent actually dead).
function pidAlive(pid: number): boolean {
try { process.kill(pid, 0); return true } catch { return false }
}
function heartbeatFresh(): boolean {
try { return Date.now() - statSync(HEARTBEAT_FILE).mtimeMs < HEARTBEAT_STALE_MS } catch { return false }
}
function incumbentHolds(): boolean {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
return pid > 1 && pid !== process.pid && pidAlive(pid) && heartbeatFresh()
} catch { return false }
}
// Set once this process becomes the active poller; cleared on shutdown.
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
process.on('unhandledRejection', err => {
process.stderr.write(`telegram-opencode: unhandled rejection: ${err}\n`)
})
process.on('uncaughtException', err => {
process.stderr.write(`telegram-opencode: uncaught exception: ${err}\n`)
})
// ============================================================================
// Access control (verbatim from the grok fork — keeps access/pairing parity)
// ============================================================================
type GroupPolicy = { requireMention: boolean; allowFrom: string[] }
type PendingEntry = {
senderId: string; chatId: string; createdAt: number; expiresAt: number; replies: number
}
type AccessJson = {
allowFrom: string[]
groups: Record<string, GroupPolicy>
ackReaction?: string
textChunkLimit?: number
dmPolicy?: 'allowlist' | 'static' | 'pairing'
pending?: Record<string, PendingEntry>
}
const DEFAULT_ACCESS: AccessJson = { allowFrom: [], groups: {}, pending: {} }
// Field defaulting only. The failure taxonomy (ENOENT vs unreadable vs corrupt)
// lives in access-core so every channel plugin answers a failed read the same
// way; the old `catch { return DEFAULT_ACCESS }` here silently denied EVERY chat
// whenever access.json was momentarily unreadable (DIVE-3962).
function normalizeAccess(raw: unknown): AccessJson {
const parsed = (raw ?? {}) as Partial<AccessJson>
return {
allowFrom: parsed.allowFrom ?? [],
groups: parsed.groups ?? {},
ackReaction: typeof parsed.ackReaction === 'string' ? parsed.ackReaction : undefined,
textChunkLimit: typeof parsed.textChunkLimit === 'number'
? Math.max(500, Math.min(4096, parsed.textChunkLimit)) : undefined,
dmPolicy: parsed.dmPolicy === 'static' ? 'static'
: parsed.dmPolicy === 'pairing' ? 'pairing' : 'allowlist',
pending: parsed.pending ?? {},
}
}
function loadAccess(): AccessJson {
return readAccessFileCore({
accessFile: ACCESS_FILE,
label: 'telegram-opencode',
normalize: normalizeAccess,
fallback: () => ({ ...DEFAULT_ACCESS, pending: {} }),
})
}
function saveAccess(a: AccessJson): void {
try {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = ACCESS_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
renameSync(tmp, ACCESS_FILE)
} catch (err) {
process.stderr.write(`telegram-opencode: saveAccess failed: ${err}\n`)
}
}
function pruneExpired(a: AccessJson): boolean {
const now = Date.now()
let changed = false
for (const [code, p] of Object.entries(a.pending ?? {})) {
if (p.expiresAt < now) { delete a.pending![code]; changed = true }
}
return changed
}
function assertInStateDir(path: string) {
let real: string, stateReal: string
try { real = realpathSync(path); stateReal = realpathSync(STATE_DIR) } catch { return }
if (real !== stateReal && !real.startsWith(stateReal + sep)) {
throw new Error(`refusing to send file outside state dir: ${path}`)
}
}
type GateResult =
| { allowed: true; access: AccessJson }
| { allowed: false }
| { allowed: false; pair: { code: string; chatId: string; isResend: boolean } }
function gate(ctx: Context): GateResult {
const access = loadAccess()
const chat = ctx.chat, from = ctx.from
if (!chat || !from) return { allowed: false }
const chatId = String(chat.id), senderId = String(from.id)
if (chat.type === 'private') {
if (access.allowFrom.includes(senderId)) return { allowed: true, access }
if (access.dmPolicy === 'pairing') {
if (pruneExpired(access)) saveAccess(access)
for (const [code, p] of Object.entries(access.pending ?? {})) {
if (p.senderId === senderId) {
if ((p.replies ?? 1) >= 2) return { allowed: false }
p.replies = (p.replies ?? 1) + 1
saveAccess(access)
return { allowed: false, pair: { code, chatId, isResend: true } }
}
}
if (Object.keys(access.pending ?? {}).length >= 3) return { allowed: false }
const code = randomBytes(3).toString('hex')
const now = Date.now()
access.pending = access.pending ?? {}
access.pending[code] = { senderId, chatId, createdAt: now, expiresAt: now + 3600_000, replies: 1 }
saveAccess(access)
return { allowed: false, pair: { code, chatId, isResend: false } }
}
return { allowed: false }
}
const policy = access.groups[chatId]
if (!policy) return { allowed: false }
const senderOk = policy.allowFrom.length === 0
? access.allowFrom.includes(senderId) : policy.allowFrom.includes(senderId)
if (!senderOk) return { allowed: false }
if (policy.requireMention && !isMentioned(ctx)) return { allowed: false }
return { allowed: true, access }
}
function isMentioned(ctx: Context): boolean {
const msg = ctx.message
if (!msg) return false
const text = msg.text ?? msg.caption ?? ''
if (!botUsername) return false
if (text.includes(`@${botUsername}`)) return true
const reply = msg.reply_to_message
if (reply && reply.from?.id === ctx.me?.id) return true
return false
}
function assertAllowedChat(chatId: string) {
const access = loadAccess()
if (access.allowFrom.includes(chatId)) return
if (access.groups[chatId]) return
throw new Error(`chat_id ${chatId} is not on the allowlist`)
}
// ============================================================================
// opencode HTTP client (REST + SSE)
// ============================================================================
// Resolved at boot by ensureServer(): the base URL of the opencode server we
// drive. Either an already-running server (OPENCODE_SERVER_URL) or one we spawn.
let ocBase = ''
const OC_USER = process.env.OPENCODE_SERVER_USERNAME ?? 'opencode'
const OC_PASS = process.env.OPENCODE_SERVER_PASSWORD ?? ''
const OC_DIR = process.env.OPENCODE_PROJECT_DIR ?? process.cwd()
function ocHeaders(extra?: Record<string, string>): Record<string, string> {
const h: Record<string, string> = { ...(extra ?? {}) }
if (OC_PASS) h['authorization'] = 'Basic ' + Buffer.from(`${OC_USER}:${OC_PASS}`).toString('base64')
return h
}
async function ocFetch(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${ocBase}${path}`, {
...init,
headers: ocHeaders({ 'content-type': 'application/json', ...(init?.headers as any) }),
})
}
// Model used for prompts: /model override file > OPENCODE_MODEL env > server
// default (omit the field). Returns {providerID, modelID} or null.
function currentModel(): { providerID: string; modelID: string } | null {
let raw = ''
try { raw = readFileSync(MODEL_FILE, 'utf8').trim() } catch {}
if (!raw) raw = process.env.OPENCODE_MODEL ?? ''
const slash = raw.indexOf('/')
if (slash <= 0) return null
return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) }
}
// Probe a base URL for a live opencode server.
async function ocAlive(base: string): Promise<boolean> {
try {
const r = await fetch(`${base}/global/health`, { headers: ocHeaders(), signal: AbortSignal.timeout(3000) })
return r.ok
} catch { return false }
}
// Bind an OS-assigned loopback port, then release it and hand the number to
// `opencode serve --port N`. Lets multiple opencode agents share one box with no
// per-agent port bookkeeping in provisioning (DIVE-42). The bind→close→reuse
// window is tiny and we're the only thing spawning servers in this state dir.
function pickFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = require('net').createServer()
srv.once('error', reject)
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
srv.close(() => port ? resolve(port) : reject(new Error('could not acquire a free port')))
})
})
}
// Attach to OPENCODE_SERVER_URL if reachable, else spawn `opencode serve`. The
// spawned server is a child of this process and dies with it (the systemd unit
// owns the lifecycle either way).
let serveChild: ReturnType<typeof import('child_process').spawn> | null = null
async function ensureServer(): Promise<void> {
const configured = process.env.OPENCODE_SERVER_URL
if (configured && await ocAlive(configured)) {
ocBase = configured.replace(/\/$/, '')
process.stderr.write(`telegram-opencode: attached to ${ocBase}\n`)
return
}
const { spawn } = require('child_process')
// Honor an explicit OPENCODE_SERVE_PORT (debug/attach), otherwise auto-pick a
// free loopback port so co-located opencode agents never collide.
const explicit = process.env.OPENCODE_SERVE_PORT
const port = explicit ? Number(explicit) : await pickFreePort()
const bin = process.env.OPENCODE_BIN ?? 'opencode'
const env = { ...process.env }
if (OC_PASS) env.OPENCODE_SERVER_PASSWORD = OC_PASS
serveChild = spawn(bin, ['serve', '--hostname', '127.0.0.1', '--port', String(port)],
{ cwd: OC_DIR, env, stdio: ['ignore', 'pipe', 'pipe'] })
serveChild!.stderr?.on('data', (d: Buffer) => process.stderr.write(`[opencode serve] ${d}`))
serveChild!.stdout?.on('data', (d: Buffer) => process.stderr.write(`[opencode serve] ${d}`))
ocBase = `http://127.0.0.1:${port}`
// Wait for it to come up.
for (let i = 0; i < 40; i++) {
if (await ocAlive(ocBase)) {
process.stderr.write(`telegram-opencode: spawned opencode serve at ${ocBase}\n`)
return
}
await new Promise(r => setTimeout(r, 500))
}
throw new Error(`opencode serve did not come up at ${ocBase} within 20s`)
}
// chat_id <-> sessionID maps. Persisted so a relay restart keeps continuity.
const chatToSession = new Map<string, string>()
const sessionToChat = new Map<string, string>()
function loadSessions(): void {
try {
const m = JSON.parse(readFileSync(SESSIONS_FILE, 'utf8')) as Record<string, string>
for (const [chat, ses] of Object.entries(m)) { chatToSession.set(chat, ses); sessionToChat.set(ses, chat) }
} catch {}
}
function saveSessions(): void {
try {
writeFileSync(SESSIONS_FILE, JSON.stringify(Object.fromEntries(chatToSession), null, 2) + '\n', { mode: 0o600 })
} catch (err) { process.stderr.write(`telegram-opencode: saveSessions failed: ${err}\n`) }
}
async function sessionForChat(chat_id: string): Promise<string> {
const existing = chatToSession.get(chat_id)
if (existing) {
// Verify it still exists server-side (a server restart drops sessions).
try {
const r = await ocFetch(`/session/${existing}`)
if (r.ok) return existing
} catch {}
}
const r = await ocFetch('/session', { method: 'POST', body: JSON.stringify({}) })
if (!r.ok) throw new Error(`POST /session failed: HTTP ${r.status}`)
const ses = (await r.json()) as { id: string }
chatToSession.set(chat_id, ses.id)
sessionToChat.set(ses.id, chat_id)
saveSessions()
return ses.id
}
// Concatenate the text parts of an assistant message response.
function extractText(parts: any[]): string {
return (parts ?? []).filter(p => p?.type === 'text' && typeof p.text === 'string')
.map(p => p.text).join('').trim()
}
// ============================================================================
// Bot
// ============================================================================
const bot = new Bot(TOKEN)
// DIVE-1674: never deliver a bare 'undefined'/empty payload to the user.
// This is the single transport choke point every send flows through, so a
// guard here kills the symptom regardless of which caller passed undefined
// (or a template that stringified to the literal string 'undefined').
bot.api.config.use((prev, method, payload, signal) => {
if (method === 'sendMessage' || method === 'editMessageText') {
const p = payload as { text?: string }
if (p.text == null || p.text.trim() === '' || p.text.trim() === 'undefined') {
throw new Error(`telegram ${method}: refusing to send empty/undefined text`)
}
protectTelegramViewerLinks(payload)
}
return prev(method, payload, signal)
})
let botUsername = ''
let shuttingDown = false
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
const TYPING_INTERVAL_MS = 4_000
const TYPING_CEILING_MS = 5 * 60 * 1000
const typingLoops = new Map<string, ReturnType<typeof setInterval>>()
const typingCeilings = new Map<string, ReturnType<typeof setTimeout>>()
function startTypingLoop(chat_id: string) {
stopTypingLoop(chat_id)
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
typingLoops.set(chat_id, setInterval(() => {
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
}, TYPING_INTERVAL_MS))
typingCeilings.set(chat_id, setTimeout(() => stopTypingLoop(chat_id), TYPING_CEILING_MS))
}
function stopTypingLoop(chat_id: string) {
const h = typingLoops.get(chat_id); if (h) { clearInterval(h); typingLoops.delete(chat_id) }
const c = typingCeilings.get(chat_id); if (c) { clearTimeout(c); typingCeilings.delete(chat_id) }
}
const TG_MAX_MESSAGE_CHARS = 4000
function chunkForTelegram(text: string, limit = TG_MAX_MESSAGE_CHARS): string[] {
if (text.length <= limit) return [text]
const out: string[] = []
let rest = text
while (rest.length > limit) {
let split = rest.lastIndexOf('\n\n', limit)
if (split < limit / 2) split = rest.lastIndexOf('\n', limit)
if (split < limit / 2) split = rest.lastIndexOf(' ', limit)
if (split < limit / 2) split = limit
out.push(rest.slice(0, split))
rest = rest.slice(split).replace(/^\s+/, '')
}
if (rest.length > 0) out.push(rest)
return out
}
// DIVE-341 (port of DIVE-332/335): auto-render a Yes/No inline keyboard when an
// assistant reply ends in a single yes/no question. Conservative on purpose — we
// only attach when there's exactly one '?' in the message, and the trailing
// question isn't an "A or B?" choice (false buttons on rhetorical/multi-part
// prompts are worse than a missed one). Opt-out: a trailing `<!-- no-buttons -->`
// (or `<!-- no-yn -->`), stripped from the outgoing text either way.
const YN_SUPPRESS = /\s*<!--\s*no-?(?:yn|buttons)\s*-->\s*$/i
function yesNoButtons(text: string): { stripped: string; keyboard?: InlineKeyboard } {
if (YN_SUPPRESS.test(text)) return { stripped: text.replace(YN_SUPPRESS, '') }
// DIVE-1429: pure polar-question detection lives in tna.ts (yesNoChoice); it
// excludes wh-questions ("what's up?") that a Yes/No answer can't address.
if (!yesNoChoice(text)) return { stripped: text }
return {
stripped: text,
keyboard: new InlineKeyboard().text('✅ Yes', 'yn:yes').text('❌ No', 'yn:no'),
}
}
// DIVE-708/717: when a reply presents a lettered/numbered CHOICE list (a) … b) …
// or 1. 2. 3.), render one tappable button per option instead of the Yes/No
// pair, so the user taps the actual choice. Detection (sequence + cue gate) is
// the pure optionChoices() in tna.ts; here we just build the keyboard. One
// button per row. callback_data is `opt:<index>`; the chosen label is re-resolved
// from the tapped message at tap time, so it never has to fit the 64-byte cap.
// Shares the YN opt-out marker (`<!-- no-buttons -->`).
const OPT_BTN_MAX = 56 // keep button text to one tidy line in the Telegram UI
function optionButtons(text: string): { keyboard?: InlineKeyboard; labels?: string[] } {
if (YN_SUPPRESS.test(text)) return {}
const opts = optionChoices(text)
if (!opts.length) return {}
const kb = new InlineKeyboard()
opts.forEach((o, i) => {
const label = o.label.length > OPT_BTN_MAX ? o.label.slice(0, OPT_BTN_MAX - 1).trimEnd() + '…' : o.label
kb.text(`${o.marker.toUpperCase()}) ${label}`, `opt:${i}`).row()
})
// Inject the FULL label (not the truncated button text) on tap.
return { keyboard: kb, labels: opts.map(o => o.label) }
}
// DIVE-708/717: remember a sent message's option labels so an `opt:<index>` tap
// resolves the exact choice text, robust to the streaming/chunking that would
// make re-parsing the displayed message unreliable. Bounded cache.
const OPTION_CAP = 200
const optionLabelsByMsg = new Map<number, string[]>()
function rememberOptions(message_id: number, labels: string[]): void {
if (optionLabelsByMsg.has(message_id)) return
if (optionLabelsByMsg.size >= OPTION_CAP) {
const oldest = optionLabelsByMsg.keys().next().value
if (oldest != null) optionLabelsByMsg.delete(oldest)
}
optionLabelsByMsg.set(message_id, labels)
}
// Send a (possibly long) reply to a chat, chunked. Reused by the relay and commands.
async function sendReply(chat_id: string, text: string, opts?: { reply_to?: number; thread?: number }): Promise<void> {
const limit = loadAccess().textChunkLimit ?? TG_MAX_MESSAGE_CHARS
const chunks = chunkForTelegram(text || '(empty reply)', limit)
for (let i = 0; i < chunks.length; i++) {
await bot.api.sendMessage(chat_id, chunks[i]!, {
...(i === 0 && opts?.reply_to != null ? { reply_parameters: { message_id: opts.reply_to } } : {}),
...(opts?.thread != null ? { message_thread_id: opts.thread } : {}),
}).catch((err: any) => process.stderr.write(`telegram-opencode: sendReply failed: ${err?.message}\n`))
}
}
let lastInboundTs: string | null = null
// ============================================================================
// Progressive streaming relay
// ============================================================================
//
// The per-token stream comes from `message.part.delta` events (field:text). We
// accumulate those and edit a Telegram message in place as it grows, so the user
// watches the reply form instead of waiting for one dump at turn-end.
//
// Three traps the live API taught us, all handled here:
// • `message.part.updated` is NOT the token stream — it fires only at part
// start (empty) and end (full). Keying off it makes the reply land as one
// block. It IS the source of truth for a part's TYPE and final text.
// • reasoning streams through the same `field:text` deltas as the answer, but
// on parts typed `reasoning`. We learn each part's type from `updated` and
// append deltas only for `text` parts, so reasoning never leaks in.
// • the user's OWN prompt echoes back as a text part (role=user) — skipped via
// the role learned from `message.updated`.
const EDIT_THROTTLE_MS = 1100
type StreamState = {
chat_id: string
ses: string
reply_to?: number
thread?: number
textParts: Map<string, string> // assistant text partID -> accumulated text
partType: Map<string, string> // partID -> opencode part type (text/reasoning/…)
order: string[] // text partIDs in first-seen order
msgIds: number[] // telegram message id per chunk
sentChunks: string[] // last text set on each chunk's message
lastEditAt: number
timer: ReturnType<typeof setTimeout> | null
started: boolean // first chunk sent (typing can stop)
finalized: boolean
finalText: string | null // authoritative text from the prompt POST
flushing: boolean
dirty: boolean
}
const streams = new Map<string, StreamState>() // sessionID -> stream
const roleByMsg = new Map<string, string>() // messageID -> role
const lastPromptByChat = new Map<string, string>() // chat_id -> last prompt text
function newStream(chat_id: string, ses: string, reply_to?: number, thread?: number): StreamState {
const s: StreamState = {
chat_id, ses, reply_to, thread,
textParts: new Map(), partType: new Map(), order: [], msgIds: [], sentChunks: [],
lastEditAt: 0, timer: null, started: false, finalized: false,
finalText: null, flushing: false, dirty: false,
}
streams.set(ses, s)
return s
}
function renderStream(s: StreamState): string {
return s.order.map(id => s.textParts.get(id) ?? '').join('').trim()
}
function targetText(s: StreamState): string {
return s.finalText ?? renderStream(s)
}
function scheduleFlush(s: StreamState): void {
if (s.timer || s.finalized) return
const wait = Math.max(0, EDIT_THROTTLE_MS - (Date.now() - s.lastEditAt))
s.timer = setTimeout(() => { s.timer = null; void flushStream(s) }, wait)
}
// Render the current target text into one-or-more Telegram messages, editing in
// place. chunkForTelegram splits greedily from the front, so once text only
// grows by appending, earlier chunk boundaries are stable — only the tail chunk
// keeps changing, and sentChunks[] dedup skips no-op edits on the settled ones.
async function flushStream(s: StreamState): Promise<void> {
if (s.flushing) { s.dirty = true; return }
s.flushing = true
try {
const full = targetText(s)
if (!full && !s.started) return
const limit = loadAccess().textChunkLimit ?? TG_MAX_MESSAGE_CHARS
const chunks = chunkForTelegram(full || '…', limit)
s.lastEditAt = Date.now()
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!
if (i < s.msgIds.length) {
if (s.sentChunks[i] === chunk) continue
try {
await bot.api.editMessageText(s.chat_id, s.msgIds[i]!, chunk)
} catch (err: any) {
const d = String(err?.description ?? err?.message ?? err)
if (!/not modified/i.test(d)) process.stderr.write(`telegram-opencode: stream edit failed: ${d}\n`)
}
s.sentChunks[i] = chunk
} else {
try {
const sent = await bot.api.sendMessage(s.chat_id, chunk, {
...(i === 0 && s.reply_to != null ? { reply_parameters: { message_id: s.reply_to } } : {}),
...(s.thread != null ? { message_thread_id: s.thread } : {}),
})
s.msgIds[i] = sent.message_id
s.sentChunks[i] = chunk
if (!s.started) { s.started = true; stopTypingLoop(s.chat_id) }
} catch (err) {
process.stderr.write(`telegram-opencode: stream send failed: ${err}\n`)
}
}
}
} finally {
s.flushing = false
if (s.dirty) { s.dirty = false; void flushStream(s) }
}
}
// Lock in the authoritative text from the prompt POST and flush one last time,
// so the final Telegram state always matches opencode's response exactly even
// if some stream events were missed.
async function finalizeStream(s: StreamState, finalText: string): Promise<void> {
const text = finalText.trim() ? finalText : (renderStream(s) || '(opencode returned no text)')
// DIVE-341: render the authoritative text minus any opt-out marker, then (after
// the final flush) attach the Yes/No keyboard to the LAST chunk's message — the
// streaming equivalent of the forks' last-chunk reply_markup.
const { stripped, keyboard: ynKeyboard } = yesNoButtons(text)
s.finalText = stripped
s.finalized = true
if (s.timer) { clearTimeout(s.timer); s.timer = null }
await flushStream(s)
if (s.dirty) { await new Promise(r => setTimeout(r, 50)); await flushStream(s) }
// DIVE-708/717: a choice-list keyboard takes precedence over Yes/No, but only
// when the reply landed as a single chunk — the tap resolves the option from
// the message it rides on, so every option must live in it.
const optRes = s.msgIds.length === 1 ? optionButtons(text) : {}
const keyboard = optRes.keyboard ?? ynKeyboard
if (keyboard && s.msgIds.length) {
const lastId = s.msgIds[s.msgIds.length - 1]!
await bot.api.editMessageReplyMarkup(s.chat_id, lastId, { reply_markup: keyboard })
.catch((err: any) => process.stderr.write(`telegram-opencode: keyboard attach failed: ${err?.message}\n`))
// Cache the option labels against the message the keyboard rides on.
if (optRes.keyboard && optRes.labels) rememberOptions(lastId, optRes.labels)
}
streams.delete(s.ses)
stopTypingLoop(s.chat_id)
}
function dropStream(ses: string): void {
const s = streams.get(ses)
if (s?.timer) { clearTimeout(s.timer); s.timer = null }
streams.delete(ses)
}
const BOT_COMMANDS: Array<{ command: string; description: string; menuHidden?: boolean }> = [
{ command: 'help', description: 'Show commands' },
{ command: 'status', description: 'Server, model, session' },
{ command: 'stop', description: 'Abort current turn' },
{ command: 'restart', description: 'Respawn opencode' },
{ command: 'agents', description: 'Team' },
{ command: 'team', description: 'Team (alias for /agents)', menuHidden: true },
{ command: 'tasks', description: 'List open tasks' },
{ command: 'task', description: 'Add a task — /task add <title>' },
{ command: 'org', description: 'Show the agent org chart' },
{ command: 'model', description: 'Pick model' },
{ command: 'ping', description: 'Liveness check' },
{ command: 'start', description: 'Pair this chat' },
]
// /team is a hidden alias: still dispatched and shown in /help, but kept off
// the BotFather command-menu picker — Mark: don't list both /agents and /team.
const MENU_COMMANDS = BOT_COMMANDS.filter(c => !c.menuHidden)
function helpText(): string {
return [
`*telegram-opencode* v${PLUGIN_VERSION} — bridge for the opencode CLI`,
``, `commands:`,
...BOT_COMMANDS.map(c => ` /${c.command} — ${c.description}`),
``,
`everything else you send is forwarded to opencode as a prompt.`,
`docs: github.com/5dive-ai/5dive-plugins/tree/main/plugins/telegram-opencode`,
].join('\n')
}
const SERVER_STARTED_AT = Date.now()
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60), sec = s % 60
if (d) return `${d}d ${h}h`
if (h) return `${h}h ${m}m`
if (m) return `${m}m ${sec}s`
return `${sec}s`
}
function execText(cmd: string, args: string[]): Promise<string | null> {
return new Promise(resolve => {
require('child_process').execFile(cmd, args, { timeout: 4000 }, (err: any, out: string) => {
resolve(err ? null : (String(out || '').split('\n')[0].trim() || null))
})
})
}
function fmtVer(raw: string): string {
const m = raw.match(/\d+\.\d+(?:\.\d+)?[\w.+-]*/)
return m ? `v${m[0]}` : raw
}
function agentName(): string {
try {
const user = require('os').userInfo().username as string
if (user.startsWith('agent-')) return user.slice('agent-'.length)
} catch {}
return 'unknown'
}
// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY.
//
// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and
// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant
// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call
// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box
// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail
// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats
// and every one a source. The reader's own catch swallowed the rejection, so
// nothing on our side ever said a word for 39 days.
//
// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an
// access change made to quiet a log, and it would outlive the need. Instead:
// try the bare binary as this seat's own uid first (`task coordinator`,
// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and
// need no root), and once sudo has refused us once, never spawn it again for the
// life of this process — that turns an unbounded mail stream into at most ONE
// message per process start even where the unprivileged path also fails.
//
// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on
// shipped boxes match the command as written today, and an absolute path would
// turn a working grant into a denial on every one of them.
let SUDO_DENIED_5DIVE = false
const SUDO_DENIAL_RE =
/(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i
function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void {
const cp = require('child_process')
const opts = { timeout, maxBuffer: 16 * 1024 * 1024 }
const okUnprivileged = (out: string): boolean => {
// `{ok:false}` is the one answer worth escalating to root for; anything else
// (including a shape with no `ok` at all) stands as this seat's answer.
try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false }
}
cp.execFile('5dive', args, opts, (err: any, stdout: string) => {
if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout)
if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '')
cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => {
if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) {
if (!SUDO_DENIED_5DIVE) {
console.error(
'[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' +
'unprivileged binary for the rest of this process and not spawning sudo again. Every ' +
'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' +
'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0],
)
}
SUDO_DENIED_5DIVE = true
}
cb(e2, out2 ?? '')
})
})
}
// Run `5dive <args> --json` (unprivileged first, sudo only as a fallback — see
// exec5dive above) and return the parsed {ok,data,error} envelope.
function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> {
return new Promise((resolve, reject) => {
exec5dive(args, timeout,
(err: any, stdout: string) => {
if (err && !stdout) return reject(err)
try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) }
})
})
}
async function read5diveInfo(): Promise<{ cliVersion?: string; authProfile?: string; model?: string } | null> {
try {
const j = await run5dive(['agent', 'info', agentName(), '--json'])
if (!j.ok || !j.data) return null
return { cliVersion: j.data.cliVersion ?? undefined, authProfile: j.data.authProfile ?? undefined, model: j.data.model ?? undefined }
} catch { return null }
}
function agentUptimeMs(): number {
const name = agentName()
if (name !== 'unknown') {
try {
const out = require('child_process').execFileSync('tmux',
['display-message', '-t', `agent-${name}`, '-p', '#{session_created}'], { timeout: 3000 }).toString().trim()
const created = Number(out) * 1000
if (created > 0) return Date.now() - created
} catch {}
}
return Date.now() - SERVER_STARTED_AT
}
async function statusText(senderName: string): Promise<string> {
const now = Date.now()
const lines = [`Paired as ${senderName}.`, '']
lines.push(`status: ${ocBase ? '🟢 connected' : '🔴 no server'}`)
lines.push(`server: ${ocBase || '(none)'}`)
const mdl = currentModel()
lines.push(`model: ${mdl ? `${mdl.providerID}/${mdl.modelID}` : '(opencode default)'}`)
lines.push(`uptime: ${formatDuration(agentUptimeMs())}`)
const info = await read5diveInfo()
if (info?.cliVersion) {
const v0 = info.cliVersion.replace(/^[A-Za-z][A-Za-z0-9-]*\s+/, '').trim() || info.cliVersion
lines.push(`opencode: ${/^\d/.test(v0) ? 'v' + v0 : v0}`)
}
lines.push(`plugin: v${PLUGIN_VERSION}`)
const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root
if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`)
lines.push(`account: ${info?.authProfile || 'default'}`)
return lines.join('\n')
}
async function listAgents(): Promise<string> {
return new Promise(resolve => {
require('child_process').execFile('sudo', ['-n', '5dive', 'agent', 'list', '--json'], { timeout: 5000 },
(err: any, stdout: string) => {
if (err) return resolve(`⚠️ \`5dive agent list\` failed: ${err.message}`)
try {
const env = JSON.parse(stdout) as { ok: boolean; data: any[] }
if (!env.ok || !Array.isArray(env.data) || env.data.length === 0) return resolve('no agents found')
const self = agentName()
const lines = [`*agents on this host* (${env.data.length}):`, '']
for (const a of env.data) {
const me = a.name === self ? ' ← me' : ''
const dot = a.active === 'active' ? '🟢' : '⚪'
lines.push(`${dot} \`${a.name}\` — ${a.type}${a.channels && a.channels !== 'none' ? ` · ${a.channels}` : ''}${me}`)
}
resolve(lines.join('\n'))
} catch (e) { resolve(`⚠️ couldn't parse \`5dive agent list\`: ${e}`) }
})
})
}
// --- /tasks: tappable list + single-task detail (host-shared queue) ---
// Open tasks render as one button each (tap -> detail); rows assigned to THIS
// agent are starred. The detail view carries a Back button that re-renders the
// list. Read-only; mutations still go through /task add and the dashboard/CLI.
function taskAssignedToMe(assignee: string | null | undefined): boolean {
if (!assignee) return false
const me = agentName()
if (!me || me === 'unknown') return false
// task assignees appear as either the bare agent name ("main") or the unix
// user form ("agent-main") in the queue — match both.
return assignee === me || assignee === `agent-${me}`
}
// Telegram inline-button labels are centered and silently clipped, so the list
// is plain left-aligned text with a tappable /task_<id> deep link per row
// (handled by the bot.hears below). Rows assigned to THIS agent are starred.
// Read-only; mutations go through /task add + dashboard/CLI.
// Clamp a list section to a ~4000-char budget so a long /tasks never exceeds
// Telegram's 4096 send limit (DIVE-313); appends "(+N more)" for the remainder.
function clampList(header: string, lines: string[], total = lines.length): string {
const BUDGET = 4000
let used = header.length
const kept: string[] = []
for (const line of lines) {
if (used + line.length + 1 > BUDGET) break
kept.push(line)
used += line.length + 1
}
const hidden = total - kept.length
return header + kept.join('\n') + (hidden > 0 ? `\n(+${hidden} more)` : '')
}
// Render one task row: ⭐ if mine, a status flag, ident · title, assignee, link.
// `needTag` appends the gate type (e.g. " [approval]") for the Needs-you section.
function taskRow(t: any, needTag = false): string {
const TITLE_MAX = 80
const mine = taskAssignedToMe(t.assignee) ? '⭐ ' : ''
const flag = t.status === 'in_progress' ? '▶ ' : t.status === 'blocked' ? '⛔ ' : ''
let title = String(t.title ?? '')
if (title.length > TITLE_MAX) title = title.slice(0, TITLE_MAX - 1) + '…'
const tag = needTag && t.need_type ? ` [${t.need_type}]` : ''
const who = t.assignee ? ` (${String(t.assignee).replace(/^agent-/, '')})` : ''
return `${mine}${flag}${t.ident} · ${title}${tag}${who} /task_${t.id}`
}
async function buildTaskList(): Promise<string> {
let j: any
try {
j = await run5dive(['task', 'ls', '--json'])
} catch (err) {
return `Failed to list tasks: ${err instanceof Error ? err.message : String(err)}`
}
if (!j.ok || !Array.isArray(j.data?.tasks)) return '5dive returned unexpected output.'
const tasks = j.data.tasks
if (tasks.length === 0) return 'No open tasks.\n\nAdd one with /task add <title>.'
const MAX = 40
// Gates actually waiting on a PERSON float to their own "Needs you" section on top.
//
// DIVE-3267: this used to read `t.need_type`, with a comment calling its presence
// "a clean needs-a-human flag". It is not: need_type present means HAS AN
// UNANSWERED GATE, and a gate routed to an agent seat is one of those. So "Needs
// you" listed every open gate in the fleet as an act-on-me row — the same defect
// DIVE-3224 fixed one command over in /inbox, still live here, and the false
// premise was written down above the line as its justification.
//
// `needs_human` is the CLI's OWN verdict on that question, computed from the one
// predicate in cmd_task_inbox. We partition on the answer; we do not rebuild the
// rule, and we must not — that copy is what produced both defects, and the rule
// has grown a clause since (DIVE-3228's routed-`access` case).
//
// FALLBACK, and note which way it fails. On a CLI predating DIVE-3267 the field is
// absent from every row, and we revert to the old `need_type` reading rather than
// treating "absent" as "not human" — which would EMPTY this section and hide the
// founder's own gates. Showing too much is recoverable; hiding a gate is the defect.
// The CLI guarantees the field is present and 0 (never omitted) on a non-human row,
// so absent-everywhere is an unambiguous version signal, not a per-row ambiguity.
//
// The SAME predicate, negated, feeds the other bucket: an agent-routed gate leaves
// "Needs you" and must land in "Open tasks" rather than falling out of both.
const hasVerdict = tasks.some((t: any) => t.needs_human !== undefined)
const needsHuman = (t: any) => (hasVerdict ? Number(t.needs_human) === 1 : !!t.need_type)
const needsYou = tasks.filter(needsHuman)
const rest = tasks.filter((t: any) => !needsHuman(t))
const sections: string[] = []
if (needsYou.length) {
const lines = needsYou.map((t: any) => taskRow(t, true))
sections.push(clampList(`🔔 Needs you (${needsYou.length}) · tap /task_N to act:\n\n`, lines))
}
if (rest.length) {
const lines = rest.slice(0, MAX).map((t: any) => taskRow(t))
sections.push(clampList('Open tasks · ⭐ = yours · tap /task_N to open:\n\n', lines, rest.length))
}
return sections.join('\n\n')
}
async function buildTaskDetail(id: number): Promise<{ text: string; keyboard?: InlineKeyboard }> {
let j: any
try {
j = await run5dive(['task', 'show', String(id), '--json'])
} catch (err) {
return { text: `Failed to load task: ${err instanceof Error ? err.message : String(err)}` }
}
if (!j.ok || !j.data?.task) return { text: 'Task not found.' }
const t = j.data.task
const mine = taskAssignedToMe(t.assignee) ? ' ⭐' : ''
const lines = [
`${t.ident} · ${t.title}`,
``,
`status: ${t.status}${t.priority ? ` · priority: ${t.priority}` : ''}`,
`assignee: ${t.assignee || '(unassigned)'}${mine}`,
]
if (t.created_by) lines.push(`created by: ${t.created_by}`)
const subs = Array.isArray(j.data.subtasks) ? j.data.subtasks : []
if (subs.length) lines.push(`subtasks: ${subs.length}`)