@rubytech/create-maxy-code 0.1.349 → 0.1.351

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.
Files changed (62) hide show
  1. package/dist/index.js +14 -0
  2. package/package.json +1 -1
  3. package/payload/platform/docs/superpowers/plans/2026-06-23-account-filesystem-schema.md +544 -0
  4. package/payload/platform/docs/superpowers/specs/2026-06-23-account-filesystem-schema-design.md +170 -0
  5. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +68 -0
  7. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +108 -0
  8. package/payload/platform/plugins/admin/skills/agent-builder/SKILL.md +107 -0
  9. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +103 -0
  10. package/payload/platform/scripts/lib/provision-account-dir.sh +22 -3
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +6 -0
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +184 -63
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/index.js +8 -0
  16. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +27 -9
  18. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -25
  20. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +1 -0
  22. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +16 -0
  24. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts +35 -0
  26. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts.map +1 -0
  27. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js +52 -0
  28. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js.map +1 -0
  29. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts +29 -0
  30. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts.map +1 -0
  31. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js +121 -0
  32. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js.map +1 -0
  33. package/payload/platform/services/telegram-channel/package.json +20 -0
  34. package/payload/platform/templates/account-schema/SCHEMA.md +54 -0
  35. package/payload/server/public/assets/{AdminLoginScreens-BEzoTk56.js → AdminLoginScreens-B1cvICYI.js} +1 -1
  36. package/payload/server/public/assets/AdminShell-B-GeoG3j.js +1 -0
  37. package/payload/server/public/assets/{Checkbox-CvTFOczj.js → Checkbox-DoGMXVpF.js} +1 -1
  38. package/payload/server/public/assets/{OperatorConversations-CnP9Y6g2.css → OperatorConversations-C8G6Gfgu.css} +1 -1
  39. package/payload/server/public/assets/{admin-Bhxa1HOy.js → admin-DF9JDP6a.js} +1 -1
  40. package/payload/server/public/assets/{browser-BAQFuyma.js → browser-Cyi6v8BS.js} +1 -1
  41. package/payload/server/public/assets/{calendar-sSr6zUjW.js → calendar-CA3_Dm8j.js} +1 -1
  42. package/payload/server/public/assets/chat-C4hHkcTu.js +1 -0
  43. package/payload/server/public/assets/data-CrJZjlQ1.js +1 -0
  44. package/payload/server/public/assets/{graph-DbGjFYPS.js → graph-CutaO0w1.js} +2 -2
  45. package/payload/server/public/assets/{graph-labels-IGIEr-uc.js → graph-labels-CH0sWvcA.js} +1 -1
  46. package/payload/server/public/assets/{operator-Ba5afDyM.js → operator-CqKk6Twn.js} +1 -1
  47. package/payload/server/public/assets/page-Bfm1FN4n.js +30 -0
  48. package/payload/server/public/assets/{public-DikYWzOd.js → public-D5lkOp3H.js} +1 -1
  49. package/payload/server/public/browser.html +4 -4
  50. package/payload/server/public/calendar.html +4 -4
  51. package/payload/server/public/chat.html +5 -5
  52. package/payload/server/public/data.html +4 -4
  53. package/payload/server/public/graph.html +6 -6
  54. package/payload/server/public/index.html +6 -6
  55. package/payload/server/public/operator.html +7 -7
  56. package/payload/server/public/public.html +5 -5
  57. package/payload/server/server.js +4657 -4482
  58. package/payload/server/public/assets/AdminShell-BG7YD4UO.js +0 -1
  59. package/payload/server/public/assets/chat-ZH4Fsyu0.js +0 -1
  60. package/payload/server/public/assets/data-CH6GNBO3.js +0 -1
  61. package/payload/server/public/assets/page-D7uwMUOy.js +0 -30
  62. /package/payload/server/public/assets/{OperatorConversations-DU8CqO-z.js → OperatorConversations-CfG1EYyP.js} +0 -0
@@ -9,7 +9,6 @@
9
9
  // DELETE /:id -> 204 (rmSync JSONL + subdir)
10
10
  // 409 if PTY still alive
11
11
  // 404 if no transcript on disk
12
- // POST /:id/input { text }
13
12
  // GET /:id/log -> { jsonlPath, sizeBytes, exists }
14
13
  // GET /:id/log?follow=1 -> streamed file content (current + new)
15
14
  // GET /:id/meta -> SessionMeta payload
@@ -21,17 +20,18 @@
21
20
  // manager does not know.
22
21
  import { Hono } from 'hono';
23
22
  import { stream } from 'hono/streaming';
24
- import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync, writeFileSync, openSync, readSync, closeSync } from 'node:fs';
25
24
  import { tmpdir } from 'node:os';
26
25
  import { buildWaChannelMcpServers, buildWaChannelArgv, waChannelMcpConfigPath, isDevChannelPrompt } from './wa-channel-mcp.js';
27
26
  import { buildWebchatChannelMcpServers, buildWebchatChannelArgv, webchatChannelMcpConfigPath } from './webchat-channel-mcp.js';
27
+ import { buildTelegramChannelMcpServers, buildTelegramChannelArgv, telegramChannelMcpConfigPath } from './telegram-channel-mcp.js';
28
28
  import { buildChannelMcpServers, buildChannelArgv, channelMcpConfigPath } from './channel-mcp.js';
29
29
  import { mergeTarget, isMultiTarget, targetKinds } from './channel-targets.js';
30
30
  import { randomUUID } from 'node:crypto';
31
31
  import { execFileSync } from 'node:child_process';
32
32
  import { buildRcChildEnv, reapplyAdminLevers } from './rc-daemon.js';
33
33
  import { isEffortLevel, isNewSessionEffortLevel, isPermissionMode, SONNET_MODEL } from '../../../lib/models/dist/index.js';
34
- import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, liveSessionIds, getPtyTrackerForTests, livePidForSession, classifyLiveness, priorExitedCountForSession, PERMISSION_MODES, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
34
+ import { spawnClaudeSession, stopSession, writeInterruptToPty, isLive, onPtyExit, livePtyCount, liveSessionIds, getPtyTrackerForTests, livePidForSession, classifyLiveness, priorExitedCountForSession, PERMISSION_MODES, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
35
35
  import { writeScopeRecord, removeScopeRecord } from './scope-record.js';
36
36
  import { writeReseatMarker, resolveReseatChain, clearReseatMarker } from './reseat-ledger.js';
37
37
  import { getScopeMainPid, defaultProcessSignals, defaultSystemctlRunner } from './systemd-scope.js';
@@ -40,7 +40,6 @@ import { resolveSettingsPermissionMode, resolveSettingsModel, resolveSettingsEff
40
40
  import { emitRcLife } from './rc-life.js';
41
41
  import { createRcChildLog } from './rc-child-log.js';
42
42
  import { readJsonlSession } from './jsonl-enumerator.js';
43
- import { watchInputSubmission, countUserEntries } from './input-postcondition.js';
44
43
  import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId, permissionModeLogPathForSessionId, findExistingJsonlForSessionId, detectRemoteControlSlug, classifyBindBlocker, } from './jsonl-path.js';
45
44
  import { basename, dirname, join } from 'node:path';
46
45
  import { validateUserTitle } from './user-title-store.js';
@@ -156,6 +155,62 @@ function finitePositive(raw, fallback) {
156
155
  const n = Number(raw);
157
156
  return Number.isFinite(n) && n > 0 ? n : fallback;
158
157
  }
158
+ /** Task 1090 Phase 2 — the post-interrupt transcript-integrity check. Reads a
159
+ * bounded tail of the session JSONL and reports whether a `tool_use` block in
160
+ * that window has no matching `tool_result` after it — the signature of an
161
+ * interrupt that landed mid-tool-call, which would corrupt a later `--resume`.
162
+ * Bounded (last 64KB) so a large transcript never blocks the post-check; the
163
+ * window only needs the last turn's tool pairing. Returns false on any read
164
+ * error (no file yet, gone) — absence of evidence is not a dangling use. */
165
+ function tailHasDanglingToolUse(jsonlPath) {
166
+ try {
167
+ if (!existsSync(jsonlPath))
168
+ return false;
169
+ const size = statSync(jsonlPath).size;
170
+ const window = 64 * 1024;
171
+ const fd = openSync(jsonlPath, 'r');
172
+ try {
173
+ const start = size > window ? size - window : 0;
174
+ const len = size - start;
175
+ const buf = Buffer.alloc(len);
176
+ readSync(fd, buf, 0, len, start);
177
+ const lines = buf.toString('utf8').split('\n');
178
+ const used = new Set();
179
+ const resulted = new Set();
180
+ for (const line of lines) {
181
+ if (!line)
182
+ continue;
183
+ let o;
184
+ try {
185
+ o = JSON.parse(line);
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ const rec = o;
191
+ const content = rec.message?.content;
192
+ if (!Array.isArray(content))
193
+ continue;
194
+ for (const c of content) {
195
+ if (c.type === 'tool_use' && typeof c.id === 'string')
196
+ used.add(c.id);
197
+ if (c.type === 'tool_result' && typeof c.tool_use_id === 'string')
198
+ resulted.add(c.tool_use_id);
199
+ }
200
+ }
201
+ for (const id of used)
202
+ if (!resulted.has(id))
203
+ return true;
204
+ return false;
205
+ }
206
+ finally {
207
+ closeSync(fd);
208
+ }
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
159
214
  /** Resolve any of the 4 keys to the canonical (sessionId, row) pair.
160
215
  * Tries: intrinsic sessionId, bridgeSessionId, bridgeSuffix, numeric pid.
161
216
  * Returns undefined when nothing matches. */
@@ -1489,13 +1544,21 @@ export function buildHttpApp(deps) {
1489
1544
  // binding. Best-effort; the store logs its own failure.
1490
1545
  deps.waChannelStore.remove(sessionId);
1491
1546
  deps.webchatChannelStore?.remove(sessionId);
1547
+ deps.telegramChannelStore?.remove(sessionId);
1492
1548
  // Task 702 — the transcript is gone; its reattach hint must go too.
1493
1549
  removeScopeRecord(sessionsDir, sessionId, deps.logger);
1494
1550
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
1495
1551
  timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
1496
1552
  return c.body(null, 204);
1497
1553
  });
1498
- app.post('/:sessionId/input', async (c) => {
1554
+ // Task 1090 Phase 1 — POST /:sessionId/interrupt writes one raw control byte
1555
+ // (ESC or Ctrl-C) to the PTY master via writeInterruptToPty: the soft
1556
+ // interrupt, session-preserving, distinct from /stop's SIGTERM recycle. The
1557
+ // byte is a body parameter so the device spike can test both candidates
1558
+ // against one live session. This logs the requested/sent lifecycle; the
1559
+ // deeper post-conditions (generation-stopped, survival, tail-reconcile) are
1560
+ // observed manually during the spike and engineered in Phase 2 on a GO.
1561
+ app.post('/:sessionId/interrupt', async (c) => {
1499
1562
  const start = Date.now();
1500
1563
  const id = c.req.param('sessionId');
1501
1564
  const row = resolveRow(deps.watcher, id);
@@ -1505,67 +1568,74 @@ export function buildHttpApp(deps) {
1505
1568
  body = await c.req.json();
1506
1569
  }
1507
1570
  catch {
1508
- timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
1571
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 400, Date.now() - start);
1509
1572
  return c.json({ error: 'body-not-json' }, 400);
1510
1573
  }
1511
- if (typeof body.text !== 'string') {
1512
- timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
1513
- return c.json({ error: 'text-required' }, 400);
1574
+ if (body.byte !== 'esc' && body.byte !== 'ctrl-c') {
1575
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 400, Date.now() - start);
1576
+ return c.json({ error: 'byte-invalid' }, 400);
1514
1577
  }
1515
- const channel = row?.channel ?? null;
1516
- const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
1578
+ const byte = body.byte;
1579
+ const trigger = typeof body.trigger === 'string' ? body.trigger : 'manual';
1580
+ const intId = `${sessionId.slice(0, 8)}-${start}`;
1581
+ deps.logger(`[webchat-interrupt] op=interrupt-requested intId=${intId} sessionId=${sessionId.slice(0, 8)} trigger=${trigger}`);
1582
+ const result = writeInterruptToPty(sessionId, byte, deps.logger);
1517
1583
  if (!result.ok) {
1518
1584
  if (result.error === 'not-found') {
1519
- timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
1585
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 404, Date.now() - start);
1520
1586
  return c.json({ error: 'session-not-found' }, 404);
1521
1587
  }
1522
1588
  if (result.error === 'exited') {
1523
- timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
1589
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 410, Date.now() - start);
1524
1590
  return c.json({ error: 'session-exited' }, 410);
1525
1591
  }
1526
- timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
1592
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 500, Date.now() - start);
1527
1593
  return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
1528
1594
  }
1529
- // Task 666 the 200 means the write was accepted, not that a turn
1530
- // submitted. Fire a bounded post-condition poll (non-awaited) that confirms
1531
- // the PTY went `busy` or a new user JSONL entry landed, or emits
1532
- // `op=input-no-turn` so an accepted-but-unsubmitted input is visible without
1533
- // waiting for the 300s turn-timeout. The 200 is no longer the success signal.
1534
- // Finite-guard the tunables: a non-numeric env override would otherwise make
1535
- // windowMs/intervalMs NaN, so the elapse check never fires and the poll spins
1536
- // forever (a timer leak per /input). Fall back to the defaults instead.
1537
- const windowMs = finitePositive(process.env.INPUT_SUBMIT_POLL_MS, 8000);
1538
- const intervalMs = finitePositive(process.env.INPUT_SUBMIT_POLL_INTERVAL_MS, 250);
1539
- const postcondJsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1540
- const baselineUserCount = countUserEntries(postcondJsonlPath);
1541
- const pid = resolveRow(deps.watcher, id)?.pid ?? null;
1542
- void watchInputSubmission({
1543
- pid,
1544
- sessionId,
1545
- bytes: result.bytes ?? 0,
1546
- baselineUserCount,
1547
- deps: {
1548
- getPtyStatus: () => deps.watcher.getBySessionId(sessionId)?.status ?? null,
1549
- getUserEntryCount: () => countUserEntries(postcondJsonlPath),
1550
- getLastEntryAgeMs: () => {
1551
- try {
1552
- return Date.now() - statSync(postcondJsonlPath).mtimeMs;
1553
- }
1554
- catch {
1555
- return null;
1556
- }
1557
- },
1558
- now: () => Date.now(),
1559
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
1560
- log: deps.logger,
1561
- windowMs,
1562
- intervalMs,
1563
- },
1564
- }).catch((err) => {
1565
- deps.logger(`op=input-postcondition-error sessionId=${sessionId.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
1595
+ deps.logger(`[webchat-interrupt] op=interrupt-sent intId=${intId} byte=${byte} bytes=${result.bytes}`);
1596
+ // Task 1090 Phase 2 confirm generation actually stopped, rather than
1597
+ // assuming the byte worked. The fs-watcher row status is `busy` while a turn
1598
+ // streams; poll it until it leaves `busy` (idle/gone) within the bounded
1599
+ // window. This is the gate the webchat send path waits on before delivering
1600
+ // a mid-turn message, so it is awaited (not fire-and-forget).
1601
+ const stopMs = finitePositive(process.env.MAXY_INTERRUPT_STOP_MS, 2500);
1602
+ const sentAt = Date.now();
1603
+ let stopped = false;
1604
+ while (Date.now() - sentAt < stopMs) {
1605
+ const st = deps.watcher.getBySessionId(sessionId)?.status;
1606
+ if (st !== 'busy') {
1607
+ stopped = true;
1608
+ break;
1609
+ }
1610
+ await new Promise((r) => setTimeout(r, 120));
1611
+ }
1612
+ const stoppedMs = stopped ? Date.now() - sentAt : null;
1613
+ if (stopped)
1614
+ deps.logger(`[webchat-interrupt] op=generation-stopped intId=${intId} stoppedMs=${stoppedMs}`);
1615
+ else
1616
+ deps.logger(`[webchat-interrupt] op=interrupt-ineffective intId=${intId} ranMs=${Date.now() - sentAt}`);
1617
+ // Non-awaited post-check (mirrors the /input postcondition watcher): a
1618
+ // delayed liveness probe catches a deferred death the synchronous return
1619
+ // cannot, and the tail reconcile surfaces a mid-tool-call interrupt that
1620
+ // would corrupt a later --resume. Both are no-event standing signals.
1621
+ const surviveMs = finitePositive(process.env.MAXY_INTERRUPT_SURVIVE_MS, 1500);
1622
+ const postPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1623
+ void (async () => {
1624
+ await new Promise((r) => setTimeout(r, surviveMs));
1625
+ const liveRow = deps.watcher.getBySessionId(sessionId);
1626
+ const alive = !!liveRow && liveRow.status !== 'gone';
1627
+ deps.logger(`[webchat-interrupt] op=survives-interrupt intId=${intId} alive=${alive}`);
1628
+ if (!alive)
1629
+ deps.logger(`[webchat-interrupt] op=interrupt-killed-session intId=${intId} alive=false`);
1630
+ const dangling = tailHasDanglingToolUse(postPath);
1631
+ deps.logger(`[webchat-interrupt] op=tail-reconcile intId=${intId} danglingToolUse=${dangling}`);
1632
+ if (dangling)
1633
+ deps.logger(`[webchat-interrupt] op=interrupt-dangling-tooluse intId=${intId}`);
1634
+ })().catch((err) => {
1635
+ deps.logger(`[webchat-interrupt] op=postcheck-error intId=${intId} message=${err instanceof Error ? err.message : String(err)}`);
1566
1636
  });
1567
- timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
1568
- return c.json({ ok: true, bytes: result.bytes }, 200);
1637
+ timed(deps.logger, 'POST', `/${id}/interrupt`, 200, Date.now() - start);
1638
+ return c.json({ ok: true, intId, byte, bytes: result.bytes, stopped, stoppedMs }, 200);
1569
1639
  });
1570
1640
  app.get('/:sessionId/log', (c) => {
1571
1641
  const start = Date.now();
@@ -1871,6 +1941,18 @@ export function buildHttpApp(deps) {
1871
1941
  typeof webchatChannelRaw.serverPath === 'string'
1872
1942
  ? webchatChannelRaw
1873
1943
  : undefined;
1944
+ // Optional telegram native-channel attach, the telegram twin of
1945
+ // webchatChannel. The binding field is senderId (not key). All three fields
1946
+ // must be present strings or the attach is skipped. A session is whatsapp
1947
+ // XOR webchat XOR telegram, so at most one channel block below fires.
1948
+ const telegramChannelRaw = body.telegramChannel;
1949
+ const telegramChannel = telegramChannelRaw !== null &&
1950
+ typeof telegramChannelRaw === 'object' &&
1951
+ typeof telegramChannelRaw.senderId === 'string' &&
1952
+ typeof telegramChannelRaw.gatewayUrl === 'string' &&
1953
+ typeof telegramChannelRaw.serverPath === 'string'
1954
+ ? telegramChannelRaw
1955
+ : undefined;
1874
1956
  // Task-962 — recover an already-live but unresponsive webchat session. When
1875
1957
  // set, the already-live branch classifies the child busy vs wedged and
1876
1958
  // respawns ONLY a wedged one (busy → idempotent-busy reply; not-live → a
@@ -1969,12 +2051,11 @@ export function buildHttpApp(deps) {
1969
2051
  }
1970
2052
  }
1971
2053
  // Task 702 — never run two processes on one sessionId. If we already own a
1972
- // live PTY for this id, the spawn is a no-op (a bridge that lost its
1973
- // in-memory index after a restart re-asks; the live PTY takes the next
1974
- // /input). If a reattached survivor scope holds the id (status=detached, a
1975
- // process this manager cannot own after its own restart and cannot feed
1976
- // via /input), stop that scope first so the fresh --resume below is the
1977
- // only process.
2054
+ // live PTY for this id, the spawn is a no-op (a caller that lost its
2055
+ // in-memory state after a restart re-asks; the live PTY takes the next
2056
+ // turn). If a reattached survivor scope holds the id (status=detached, a
2057
+ // process this manager cannot own after its own restart and cannot drive),
2058
+ // stop that scope first so the fresh --resume below is the only process.
1978
2059
  // Task-962 — set on a wedged-child respawn so the spawn-success response
1979
2060
  // below reports the recovery verdict to the gateway.
1980
2061
  let recoverOutcome = null;
@@ -2144,6 +2225,7 @@ export function buildHttpApp(deps) {
2144
2225
  // web), and the two are fanned by one unified channel server below.
2145
2226
  let effectiveWaChannel;
2146
2227
  let effectiveWebchatChannel;
2228
+ let effectiveTelegramChannel;
2147
2229
  if (sessionId) {
2148
2230
  if (waChannel) {
2149
2231
  effectiveWaChannel = waChannel;
@@ -2167,6 +2249,17 @@ export function buildHttpApp(deps) {
2167
2249
  deps.logger(`[rc-spawn] op=webchat-channel-rebind sessionId=${sessionId.slice(0, 8)} key=${stored.key} source=store`);
2168
2250
  }
2169
2251
  }
2252
+ if (telegramChannel) {
2253
+ effectiveTelegramChannel = telegramChannel;
2254
+ deps.telegramChannelStore?.set(sessionId, telegramChannel);
2255
+ }
2256
+ else {
2257
+ const stored = deps.telegramChannelStore?.get(sessionId);
2258
+ if (stored) {
2259
+ effectiveTelegramChannel = { senderId: stored.senderId, gatewayUrl: stored.gatewayUrl, serverPath: stored.serverPath };
2260
+ deps.logger(`[rc-spawn] op=telegram-channel-rebind sessionId=${sessionId.slice(0, 8)} senderId=${stored.senderId} source=store`);
2261
+ }
2262
+ }
2170
2263
  }
2171
2264
  // Task 933 — read the persisted sidecar once so the identity gate below can
2172
2265
  // resolve role + name + adminUserId from disk when the caller omitted them
@@ -2193,6 +2286,7 @@ export function buildHttpApp(deps) {
2193
2286
  targetSet = mergeTarget(targetSet, { kind: 'webchat', ...effectiveWebchatChannel });
2194
2287
  let waPath = null;
2195
2288
  let webchatPath = null;
2289
+ let telegramPath = null;
2196
2290
  if (sessionId && isMultiTarget(targetSet)) {
2197
2291
  const unifiedServers = buildChannelMcpServers({ sessionId, targets: targetSet });
2198
2292
  const unifiedPath = channelMcpConfigPath(sessionId, tmpdir());
@@ -2244,6 +2338,29 @@ export function buildHttpApp(deps) {
2244
2338
  deps.logger(`[rc-spawn] op=webchat-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
2245
2339
  }
2246
2340
  }
2341
+ // Telegram is a standalone per-kind channel (not in the unified target
2342
+ // set). A session is whatsapp XOR webchat XOR telegram, so the telegram
2343
+ // block fires only when neither wa nor webchat is bound, so exactly one
2344
+ // channel argv block ever runs.
2345
+ if (effectiveTelegramChannel && sessionId && !effectiveWaChannel && !effectiveWebchatChannel) {
2346
+ const telegramServers = buildTelegramChannelMcpServers({
2347
+ sessionId,
2348
+ senderId: effectiveTelegramChannel.senderId,
2349
+ gatewayUrl: effectiveTelegramChannel.gatewayUrl,
2350
+ serverPath: effectiveTelegramChannel.serverPath,
2351
+ role: rcRole,
2352
+ });
2353
+ telegramPath = telegramChannelMcpConfigPath(sessionId, tmpdir());
2354
+ try {
2355
+ writeFileSync(telegramPath, JSON.stringify({ mcpServers: telegramServers }, null, 2), 'utf8');
2356
+ argv.push(...buildTelegramChannelArgv(telegramPath));
2357
+ deps.logger(`[rc-spawn] op=telegram-channel senderId=${effectiveTelegramChannel.senderId} gateway=${effectiveTelegramChannel.gatewayUrl} path=${telegramPath}`);
2358
+ }
2359
+ catch (err) {
2360
+ telegramPath = null;
2361
+ deps.logger(`[rc-spawn] op=telegram-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
2362
+ }
2363
+ }
2247
2364
  }
2248
2365
  // Task 889 — a webchat-bound admin rc session is the /chat admin surface.
2249
2366
  // Inject the authenticated-identity line (resolved name, or the explicit
@@ -2400,6 +2517,10 @@ export function buildHttpApp(deps) {
2400
2517
  if (sessionId && deps.webchatChannelStore?.get(sessionId) && webchatPath === null) {
2401
2518
  deps.logger(`[rc-spawn] op=webchat-channel-binding-lost sessionId=${sessionId} pid=${pty.pid} reason=resume-without-mcp-config`);
2402
2519
  }
2520
+ // The same post-condition for the telegram binding.
2521
+ if (sessionId && deps.telegramChannelStore?.get(sessionId) && telegramPath === null) {
2522
+ deps.logger(`[rc-spawn] op=telegram-channel-binding-lost sessionId=${sessionId} pid=${pty.pid} reason=resume-without-mcp-config`);
2523
+ }
2403
2524
  // Tracker registration — closes the leak gap the Task 552 path opened.
2404
2525
  // For fresh spawns without a caller-supplied sessionId we register the
2405
2526
  // tracker under the unitToken so the entry has a stable key; the
@@ -2435,7 +2556,7 @@ export function buildHttpApp(deps) {
2435
2556
  // a failed classification sidecar must not tear it down, so it logs and the
2436
2557
  // session keeps running (the pre-fix reader-blind behaviour, no worse).
2437
2558
  const classifyChannel = rcRole !== undefined ? adminChannel : undefined;
2438
- const classifySenderId = adminSenderId ?? effectiveWaChannel?.senderId ?? effectiveWebchatChannel?.key;
2559
+ const classifySenderId = adminSenderId ?? effectiveWaChannel?.senderId ?? effectiveWebchatChannel?.key ?? effectiveTelegramChannel?.senderId;
2439
2560
  if (sessionId && classifyChannel && classifySenderId) {
2440
2561
  const classifySidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
2441
2562
  if (!existsSync(classifySidecarPath)) {
@@ -2550,8 +2671,8 @@ export function buildHttpApp(deps) {
2550
2671
  // no literal spaces, so match on the ANSI/space-stripped letter stream.
2551
2672
  // Bounded startup keystroke only — inbound message submission stays a
2552
2673
  // channel event (the whole point of the migration). Armed only when a
2553
- // native channel (waChannel or webchatChannel, Task 772) is attached,
2554
- // fired at most once the prompt text is channel-agnostic.
2674
+ // native channel (waChannel, webchatChannel, or telegramChannel) is
2675
+ // attached, fired at most once. The prompt text is channel-agnostic.
2555
2676
  let devChannelConfirmed = false;
2556
2677
  let devChannelScan = '';
2557
2678
  let resolveStdoutSlug = () => { };
@@ -2560,7 +2681,7 @@ export function buildHttpApp(deps) {
2560
2681
  pty.onData((data) => {
2561
2682
  stdoutBytes += data.length;
2562
2683
  childLog.write(data);
2563
- if ((effectiveWaChannel || effectiveWebchatChannel) && !devChannelConfirmed) {
2684
+ if ((effectiveWaChannel || effectiveWebchatChannel || effectiveTelegramChannel) && !devChannelConfirmed) {
2564
2685
  devChannelScan += data;
2565
2686
  if (devChannelScan.length > 4096)
2566
2687
  devChannelScan = devChannelScan.slice(devChannelScan.length - 4096);