typeclaw 0.30.0 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Manual acceptance check for the sandbox.realProc strategy (src/sandbox/build.ts).
3
+ # Not a unit test: it needs a Linux container with CAP_SYS_ADMIN, which the macOS
4
+ # dev host and standard CI runners cannot provide, so it lives here as an
5
+ # operator-runnable script instead of a skipIf-everywhere test.
6
+ #
7
+ # Proves two properties of the two-phase `unshare --mount-proc -- bwrap` sandbox:
8
+ # 1. An external package runner (bunx) runs to completion (no Bun "NotDir").
9
+ # 2. A secret in a sibling process's environment NEVER appears in any
10
+ # /proc/*/environ the sandbox can read (PID-namespace scoping holds).
11
+ #
12
+ # Usage: scripts/verify-realproc-sandbox.sh [image]
13
+ # image defaults to ghcr.io/typeclaw/typeclaw-base:<version-from-package.json>
14
+ set -euo pipefail
15
+
16
+ IMAGE="${1:-}"
17
+ if [ -z "$IMAGE" ]; then
18
+ version="$(node -p "require('./package.json').version" 2>/dev/null || echo latest)"
19
+ IMAGE="ghcr.io/typeclaw/typeclaw-base:${version}"
20
+ fi
21
+
22
+ secret="TYPECLAW_REALPROC_LEAK_CANARY_$$"
23
+
24
+ inner='
25
+ echo "=== bunx via real-proc sandbox ==="
26
+ bunx cowsay "real-proc ok" 2>&1 | tail -6
27
+ echo "bunx exit=$?"
28
+ echo "=== visible pids (sandbox should NOT see the canary holder) ==="
29
+ ls /proc | grep -E "^[0-9]+$" | tr "\n" " "; echo
30
+ echo "=== leak scan ==="
31
+ found=0
32
+ for f in /proc/[0-9]*/environ; do
33
+ if tr "\0" "\n" < "$f" 2>/dev/null | grep -q "CANARY_TOKEN"; then
34
+ echo "LEAK:$f"; found=1
35
+ fi
36
+ done
37
+ if [ $found -eq 0 ]; then echo "NO_LEAK_CONFIRMED"; else echo "LEAK_DETECTED"; exit 1; fi
38
+ '
39
+ inner="${inner//CANARY_TOKEN/$secret}"
40
+
41
+ # The real-proc argv shape mirrors buildArgv() in src/sandbox/build.ts. Keep in
42
+ # sync if that helper changes.
43
+ runner="
44
+ ${secret}_holder() { :; }
45
+ env CANARY=${secret} sleep 120 &
46
+ unshare --pid --fork --mount --mount-proc -- \
47
+ bwrap --unshare-user --unshare-ipc --unshare-uts --unshare-cgroup \
48
+ --new-session --die-with-parent --clearenv \
49
+ --setenv PATH /usr/local/bin:/usr/bin:/bin --setenv HOME /tmp --setenv LANG C.UTF-8 \
50
+ --ro-bind /usr /usr --ro-bind /etc /etc --dev /dev --tmpfs /tmp \
51
+ --ro-bind-try /bin /bin --ro-bind-try /sbin /sbin --ro-bind-try /lib /lib --ro-bind-try /lib64 /lib64 \
52
+ --ro-bind /proc /proc \
53
+ bash -c '$inner'
54
+ "
55
+
56
+ echo "Image: $IMAGE"
57
+ docker run --rm --security-opt seccomp=unconfined --cap-add SYS_ADMIN \
58
+ -e "CANARY=${secret}" "$IMAGE" bash -c "$runner"
@@ -23,6 +23,7 @@ import {
23
23
  checkNonWorkspaceWriteGuard,
24
24
  checkSkillAuthoringGuard,
25
25
  } from '@/bundled-plugins/guard/policy'
26
+ import { config } from '@/config/config'
26
27
  import type { PermissionService } from '@/permissions/permissions'
27
28
  import type {
28
29
  BuiltinToolRef,
@@ -582,6 +583,17 @@ async function applyBashSandbox(
582
583
  // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
583
584
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
584
585
  // path does not run when the command is rewritten to a bwrap invocation).
586
+ // 'real-proc' gives a sandboxed JS package runner a working /proc/self/{fd,
587
+ // maps} so `bunx`/`bun add`/`bun run <pkg>` stop aborting with Bun's NotDir.
588
+ // Opt-in (default 'tmpfs') because it makes start.ts grant the container
589
+ // CAP_SYS_ADMIN at boot. Read from the boot-time `config` snapshot, NOT live
590
+ // getConfig(): sandbox.realProc is restart-required, and the strategy MUST
591
+ // track the boot-time capability. A `typeclaw reload` that flips realProc to
592
+ // true would otherwise make this emit `unshare --mount-proc` in a container
593
+ // booted WITHOUT CAP_SYS_ADMIN, so the mount fails instead of the old tmpfs
594
+ // strategy holding until restart. `config` never changes on reload.
595
+ // procSelfExe is only consumed by the 'tmpfs' branch.
596
+ const realProc = config.sandbox.realProc
585
597
  const { commandString } = buildSandboxedCommand(command, {
586
598
  mounts: [
587
599
  { type: 'ro-bind', source: agentDir, dest: agentDir },
@@ -592,6 +604,7 @@ async function applyBashSandbox(
592
604
  protected: protectedZones,
593
605
  network: 'inherit',
594
606
  cwd: agentDir,
607
+ proc: realProc ? 'real-proc' : 'tmpfs',
595
608
  procSelfExe: resolveProcSelfExe(),
596
609
  ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
597
610
  })
@@ -93,7 +93,7 @@ Delegate focused work to subagents via \`spawn_subagent\`, \`subagent_output\`,
93
93
 
94
94
  There are three delegation modes. Pick deliberately.
95
95
 
96
- **Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer. Match the worker to the depth: a fast or narrow web lookup goes to \`scout\`; a fuzzy question that needs decomposition, many sources, cross-validation, and a synthesized verdict goes to \`researcher\` (don't do that grind inline with \`web_search\` yourself).
96
+ **Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer. Match the worker to the depth: a fast or narrow web lookup goes to \`scout\`; a fuzzy question that needs decomposition, many sources, cross-validation, and a synthesized verdict goes to \`researcher\` (don't do that grind inline with \`web_search\` yourself). When the user *explicitly* says "research"/"investigate" (or equivalent), you MUST spawn \`researcher\` — answering from training memory or a single inline \`web_search\` does not satisfy the request, even if you think you know the answer. (Fanning out \`scout\`/\`explorer\` underneath is fine, but it does not replace \`researcher\`.)
97
97
 
98
98
  **Mode B — Delegate-and-converse.** Asked to DO something long-running (>~30s: installs, builds, \`docker\`, scrapes, long test suites, multi-host loops, any noisy "fetch N and synthesize" chain)? Don't run it inline — blocking your own \`bash\` freezes the conversation and stalls the channel typing heartbeat (\`MAX_TYPING_HEARTBEAT_MS\`). Spawn one subagent (\`operator\` for side effects, \`scout\` for a quick web lookup, \`researcher\` for a deep multi-source "fetch N and synthesize" investigation, \`planner\` when a multi-step goal needs a sequenced, risk-aware plan before anyone acts) with \`run_in_background: true\`, acknowledge, and KEEP TALKING. Single fast calls (\`git status\`, one known-endpoint \`curl\`) stay inline. When the completion reminder lands, weave the result in; in a channel session, the completion \`<system-reminder>\` is NOT a user message but plain text is still invisible — Surface the result via \`channel_reply\` (or \`channel_send\`). If you already posted the substantive answer in the spawn turn, prefer \`skip_response({ reason: "result confirms prior reply" })\` over going silent.
99
99
 
@@ -1,13 +1,17 @@
1
1
  import type { ReviewVerdict } from '@/channels/github-review-turn-ledger'
2
2
 
3
+ // `NONE` covers "never reviewed" and "last decisive review was DISMISSED" — both
4
+ // mean a fresh verdict is legitimate (not a duplicate).
5
+ export type EffectiveVerdict = 'APPROVED' | 'CHANGES_REQUESTED' | 'NONE'
6
+
3
7
  export type EffectiveApprovalResolver = (target: {
4
8
  workspace: string
5
9
  prNumber: number
6
- }) => Promise<{ ok: true; alreadyApproved: boolean } | { ok: false }>
10
+ }) => Promise<{ ok: true; effective: EffectiveVerdict } | { ok: false }>
7
11
 
8
12
  export type ApproveBlock = { block: true; reason: string }
9
13
 
10
- export type ApproveIdempotencyGuard = {
14
+ export type ReviewVerdictGuard = {
11
15
  guard: (args: {
12
16
  callId: string
13
17
  workspace: string
@@ -17,78 +21,135 @@ export type ApproveIdempotencyGuard = {
17
21
  release: (args: { callId: string; succeeded: boolean }) => void
18
22
  }
19
23
 
20
- const DUPLICATE_REASON =
21
- 'This bot has already approved this pull request. A second APPROVE would post a redundant review. ' +
22
- 'If you intended to change your verdict, request changes or dismiss the prior review instead of re-approving.'
24
+ // Back-compat alias: the guard now covers REQUEST_CHANGES too, not just APPROVE.
25
+ export type ApproveIdempotencyGuard = ReviewVerdictGuard
26
+
27
+ function duplicateReason(verdict: ReviewVerdict): string {
28
+ if (verdict === 'APPROVE') {
29
+ return (
30
+ 'This bot already holds a standing APPROVED review on this pull request. A second APPROVE would ' +
31
+ 'post a redundant review. If you intended to change your verdict, request changes or dismiss the ' +
32
+ 'prior review instead of re-approving.'
33
+ )
34
+ }
35
+ return (
36
+ 'This bot already holds a standing CHANGES_REQUESTED review on this pull request. A second ' +
37
+ 'REQUEST_CHANGES would post a redundant blocking review. The prior review is still live — push a fix ' +
38
+ 'and APPROVE, or reply in the existing thread, instead of re-requesting changes.'
39
+ )
40
+ }
41
+
42
+ const CONCURRENT_REASON =
43
+ 'Another session in this agent is already submitting a formal review verdict for this pull request. ' +
44
+ 'Only one verdict may land per PR — do not submit a second review; the in-flight one will post.'
45
+
46
+ // The standing verdict a fresh attempt would duplicate. APPROVE duplicates a
47
+ // standing APPROVED; REQUEST_CHANGES duplicates a standing CHANGES_REQUESTED.
48
+ function duplicatesStanding(verdict: ReviewVerdict, effective: EffectiveVerdict): boolean {
49
+ return verdict === 'APPROVE' ? effective === 'APPROVED' : effective === 'CHANGES_REQUESTED'
50
+ }
51
+
52
+ // How long a reservation may sit before it is treated as abandoned. A normal
53
+ // `gh` review submit completes in seconds; this only guards against a tool.after
54
+ // that never fires (crash mid-command), so it must outlast a slow command yet
55
+ // never strand a PR for long.
56
+ const LEASE_TTL_MS = 5 * 60_000
57
+
58
+ type Reservation = { key: string; token: number; createdAt: number }
23
59
 
24
- // Makes formal `gh ... event=APPROVE` idempotent per PR across turns, sessions,
25
- // and restarts. Two layers, each with a single job:
60
+ // MODULE-LEVEL singletons, shared by every plugin instance in this process. The
61
+ // github-cli-auth plugin's `plugin: async (ctx) => ...` factory may run once per
62
+ // session, giving each its own closure — but all of those closures import THIS
63
+ // module, so they coordinate through one Map. A closure-local Set (the prior
64
+ // design) could not see a concurrent session's in-flight verdict, which is how
65
+ // three sessions each landed an APPROVE on the same PR within ten seconds.
66
+ const inFlightByPr = new Map<string, Reservation>()
67
+ const reservationByCall = new Map<string, Reservation>()
68
+ let tokenSeq = 0
69
+
70
+ // Makes a formal `gh ... event=APPROVE|REQUEST_CHANGES` idempotent per PR across
71
+ // turns, sessions, and (in-process) concurrent fan-out. Two layers:
72
+ //
73
+ // 1. A process-wide in-flight lease keyed by `workspace#prNumber`, held from
74
+ // tool.before through tool.after. While one verdict is mid-flight, every
75
+ // other session's verdict for the same PR is blocked — even though GitHub
76
+ // has not yet recorded the in-flight review. This is the layer the old
77
+ // closure-local Set could not provide: separate plugin instances meant
78
+ // separate Sets, so concurrent sessions never saw each other.
26
79
  //
27
- // 1. An in-process set of *in-flight* reservations (`pendingApprovals`) that
28
- // blocks a second APPROVE while a first is still mid-flight in the same
29
- // container the concurrent-double-approve case the remote read can't see
30
- // yet (GitHub hasn't recorded the in-flight review).
31
- // 2. The authoritative GitHub effective-state read, the SOLE source of truth
32
- // for "the bot already holds a standing APPROVED review." It understands
33
- // supersession: a later CHANGES_REQUESTED / DISMISSED demotes an earlier
34
- // APPROVED, so the bot may legitimately re-approve.
80
+ // 2. The authoritative GitHub effective-state read, consulted AFTER the lease
81
+ // is acquired. It catches the cross-restart case (lease lost) and tracks
82
+ // supersession: a later CHANGES_REQUESTED/DISMISSED demotes an earlier
83
+ // APPROVED, so a genuine re-verdict is allowed. Reads fail OPEN — a
84
+ // transient error must never strand a genuine first verdict; the lease
85
+ // still covers the concurrent case while the command runs.
35
86
  //
36
- // The set is strictly an in-flight lock never a persistent "already approved"
37
- // memory. A completed APPROVE drops its reservation in release(), so the next
38
- // APPROVE re-consults GitHub instead of being shadowed by a stale local entry.
39
- // That separation fixes the strand bug: once a standing approval is superseded
40
- // (PR back to CHANGES_REQUESTED), a stale local lock must not keep blocking a
41
- // genuine re-approve — only the remote read decides, and it now reports
42
- // alreadyApproved=false. Reads fail OPEN: a transient GitHub error must never
43
- // permanently strand a first approval; the in-flight reservation still covers
44
- // the concurrent case.
87
+ // The lease is released only in release() (tool.after) or on a terminal block,
88
+ // never after the remote read releasing early reopens the TOCTOU the lease
89
+ // exists to close. Release is keyed by a per-call token so a late/stale
90
+ // tool.after for a superseded reservation cannot drop a newer session's lease.
45
91
  export function createApproveIdempotencyGuard(deps: {
46
92
  resolveEffectiveApproval: EffectiveApprovalResolver
47
- }): ApproveIdempotencyGuard {
48
- const pendingApprovals = new Set<string>()
49
- const reservedByCall = new Map<string, string>()
93
+ now?: () => number
94
+ }): ReviewVerdictGuard {
95
+ const now = deps.now ?? Date.now
50
96
 
51
97
  return {
52
98
  async guard(args): Promise<ApproveBlock | null> {
53
- if (args.verdict !== 'APPROVE') return null
99
+ if (args.verdict !== 'APPROVE' && args.verdict !== 'REQUEST_CHANGES') return null
54
100
  const key = prKey(args.workspace, args.prNumber)
55
101
 
56
- // Reserve BEFORE the await so two calls racing into guard() for the same
57
- // PR cannot both observe an empty set: the loser sees the winner's
58
- // in-flight reservation and is blocked. The reservation is provisional
59
- // and is always cleared on a terminal path (block below or release()).
60
- if (pendingApprovals.has(key)) return { block: true, reason: DUPLICATE_REASON }
61
- pendingApprovals.add(key)
62
- reservedByCall.set(args.callId, key)
102
+ // Reserve BEFORE the await so two calls racing into guard() for the same PR
103
+ // cannot both observe an empty map: the loser sees the winner's in-flight
104
+ // lease and is blocked. An expired lease (tool.after never fired) is
105
+ // reclaimable so a crash cannot permanently strand the PR.
106
+ const held = inFlightByPr.get(key)
107
+ if (held !== undefined && now() - held.createdAt < LEASE_TTL_MS) {
108
+ return { block: true, reason: CONCURRENT_REASON }
109
+ }
110
+ const reservation: Reservation = { key, token: ++tokenSeq, createdAt: now() }
111
+ inFlightByPr.set(key, reservation)
112
+ reservationByCall.set(args.callId, reservation)
63
113
 
64
114
  const remote = await deps.resolveEffectiveApproval({ workspace: args.workspace, prNumber: args.prNumber })
65
- if (remote.ok && remote.alreadyApproved) {
66
- // Standing approval upstream. Block, and release the in-flight lock now:
67
- // a blocked command never reaches tool.after, so release() won't run for
68
- // this callId. Leaving the key set would resurrect the strand bug — the
69
- // GitHub read is authoritative for the standing-approval case, not a
70
- // lingering local entry.
71
- reservedByCall.delete(args.callId)
72
- pendingApprovals.delete(key)
73
- return { block: true, reason: DUPLICATE_REASON }
115
+ if (remote.ok && duplicatesStanding(args.verdict, remote.effective)) {
116
+ // Standing verdict upstream already matches. Block, and release the lease
117
+ // now: a blocked command never reaches tool.after, so release() won't run
118
+ // for this callId. Leaving the lease set would resurrect the strand bug —
119
+ // the GitHub read is authoritative for the standing case.
120
+ releaseReservation(args.callId, reservation)
121
+ return { block: true, reason: duplicateReason(args.verdict) }
74
122
  }
75
123
 
76
124
  return null
77
125
  },
78
126
 
79
127
  release(args): void {
80
- const key = reservedByCall.get(args.callId)
81
- if (key === undefined) return
82
- reservedByCall.delete(args.callId)
83
- // Always drop the in-flight lock, success or fail. On success the standing
84
- // approval now lives on GitHub, so future APPROVEs are caught by the remote
85
- // read (which tracks supersession); the local lock must not outlive the
86
- // in-flight window and shadow that read.
87
- pendingApprovals.delete(key)
128
+ const reservation = reservationByCall.get(args.callId)
129
+ if (reservation === undefined) return
130
+ releaseReservation(args.callId, reservation)
88
131
  },
89
132
  }
90
133
  }
91
134
 
135
+ // Drop the lease only if THIS reservation still owns the key. A stale tool.after
136
+ // for a reservation that was already superseded (e.g. reclaimed after TTL by a
137
+ // newer session) must not yank the live session's lease.
138
+ function releaseReservation(callId: string, reservation: Reservation): void {
139
+ reservationByCall.delete(callId)
140
+ const current = inFlightByPr.get(reservation.key)
141
+ if (current !== undefined && current.token === reservation.token) {
142
+ inFlightByPr.delete(reservation.key)
143
+ }
144
+ }
145
+
92
146
  function prKey(workspace: string, prNumber: number): string {
93
147
  return `${workspace}#${prNumber}`
94
148
  }
149
+
150
+ // Test-only: clear the process-wide lease state between cases.
151
+ export function __resetReviewVerdictGuardForTest(): void {
152
+ inFlightByPr.clear()
153
+ reservationByCall.clear()
154
+ tokenSeq = 0
155
+ }
@@ -1,13 +1,12 @@
1
1
  import { GITHUB_API_BASE, githubJsonHeaders } from '@/channels/adapters/github/auth-pat'
2
2
 
3
- import type { EffectiveApprovalResolver } from './approve-idempotency'
4
-
5
- // Resolves whether THIS bot already has a standing APPROVED review on a PR, used
6
- // by the approve-idempotency guard to stop a second formal APPROVE after a
7
- // restart (the in-process pending set covers the same-container case but is lost
8
- // when the container bounces). Every failure returns { ok: false } so the guard
9
- // fails open — a transient read error must never permanently block a genuine
10
- // first approval.
3
+ import type { EffectiveApprovalResolver, EffectiveVerdict } from './approve-idempotency'
4
+
5
+ // Resolves THIS bot's standing decisive review on a PR, used by the review
6
+ // verdict guard to stop a second formal verdict after a restart (the in-process
7
+ // lease covers the same-container case but is lost when the container bounces).
8
+ // Every failure returns { ok: false } so the guard fails open — a transient read
9
+ // error must never permanently block a genuine first verdict.
11
10
  export function createGithubEffectiveApprovalResolver(deps: {
12
11
  resolveToken: (workspace: string) => Promise<string | null>
13
12
  fetchImpl?: typeof fetch
@@ -27,10 +26,16 @@ export function createGithubEffectiveApprovalResolver(deps: {
27
26
  if (reviews === null) return { ok: false }
28
27
 
29
28
  const lastDecisive = reviews.filter((r) => isSelf(r.login, r.isBot, self) && isDecisive(r.state)).at(-1)
30
- return { ok: true, alreadyApproved: lastDecisive?.state === 'APPROVED' }
29
+ return { ok: true, effective: toEffective(lastDecisive?.state) }
31
30
  }
32
31
  }
33
32
 
33
+ function toEffective(state: string | undefined): EffectiveVerdict {
34
+ if (state === 'APPROVED') return 'APPROVED'
35
+ if (state === 'CHANGES_REQUESTED') return 'CHANGES_REQUESTED'
36
+ return 'NONE'
37
+ }
38
+
34
39
  // A bot's effective review is its LATEST decisive one. COMMENTED/PENDING are
35
40
  // non-deciding noise that must not clear an earlier APPROVED/CHANGES_REQUESTED;
36
41
  // a later CHANGES_REQUESTED or DISMISSED supersedes an earlier APPROVED. The
@@ -11,7 +11,7 @@ import { classifyGhToken } from './token-class'
11
11
  export default definePlugin({
12
12
  plugin: async (ctx) => {
13
13
  const resolveTokenForRepo = ctx.github.resolveTokenForRepo
14
- const approveGuard = createApproveIdempotencyGuard({
14
+ const verdictGuard = createApproveIdempotencyGuard({
15
15
  resolveEffectiveApproval: createGithubEffectiveApprovalResolver({
16
16
  resolveToken: async (workspace) => {
17
17
  const result = await resolveTokenForRepo(workspace)
@@ -28,7 +28,7 @@ export default definePlugin({
28
28
 
29
29
  const review = await noteReviewCommand({ callId: event.callId, command })
30
30
  if (review.detected !== null) {
31
- const block = await approveGuard.guard({
31
+ const block = await verdictGuard.guard({
32
32
  callId: event.callId,
33
33
  workspace: review.detected.workspace,
34
34
  prNumber: review.detected.prNumber,
@@ -70,7 +70,7 @@ export default definePlugin({
70
70
  callId: event.callId,
71
71
  result: event.result,
72
72
  })
73
- approveGuard.release({ callId: event.callId, succeeded: committed })
73
+ verdictGuard.release({ callId: event.callId, succeeded: committed })
74
74
  },
75
75
  },
76
76
  }
@@ -0,0 +1,191 @@
1
+ // Discord renders no GitHub-flavored Markdown tables — a `| a | b |` block
2
+ // shows up as literal pipes and dashes, so an agent reply that leans on a table
3
+ // (very common) becomes unreadable. Discord DOES preserve whitespace verbatim
4
+ // inside inline code spans, so we re-emit each table row as a single
5
+ // backtick-wrapped line with columns padded to a fixed width. Columns line up
6
+ // because every row is the same monospaced inline-code span. The header row is
7
+ // additionally wrapped in `**...**` so it reads as a bold caption above the body.
8
+ //
9
+ // This is a line-walker, not a Markdown parser: it only touches blocks that
10
+ // match the pipe-table shape (a `|`-bearing line followed by a `|---|` alignment
11
+ // row) and leaves every other byte — prose, code fences, lists — untouched.
12
+
13
+ const TABLE_SEP_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/
14
+ const FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/
15
+
16
+ export function convertDiscordTables(input: string): string {
17
+ if (input === '') return ''
18
+ if (!input.includes('|')) return input
19
+
20
+ const lines = input.split('\n')
21
+ const out: string[] = []
22
+ let i = 0
23
+ let openFence: string | null = null
24
+
25
+ while (i < lines.length) {
26
+ const line = lines[i]!
27
+
28
+ // A code fence (``` / ~~~) suspends table detection until it closes — a
29
+ // table-shaped block inside a fence is literal text, not a table. The close
30
+ // must use the same fence char and be at least as long as the opener, per
31
+ // CommonMark.
32
+ const fence = FENCE_RE.exec(line)
33
+ if (fence !== null) {
34
+ const marker = fence[2]!
35
+ if (openFence === null) {
36
+ openFence = marker
37
+ } else if (marker[0] === openFence[0] && marker.length >= openFence.length) {
38
+ openFence = null
39
+ }
40
+ out.push(line)
41
+ i++
42
+ continue
43
+ }
44
+ if (openFence !== null) {
45
+ out.push(line)
46
+ i++
47
+ continue
48
+ }
49
+
50
+ // A table needs a `|`-bearing header line immediately followed by the
51
+ // alignment row; same disambiguation rule chunkMarkdown uses so a stray
52
+ // leading `|` in prose is not mistaken for a table.
53
+ if (line.includes('|') && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1]!)) {
54
+ const start = i
55
+ i += 2
56
+ while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') {
57
+ i++
58
+ }
59
+ const tableLines = lines.slice(start, i)
60
+ out.push(renderTable(tableLines))
61
+ continue
62
+ }
63
+ out.push(line)
64
+ i++
65
+ }
66
+
67
+ return out.join('\n')
68
+ }
69
+
70
+ function renderTable(tableLines: string[]): string {
71
+ const headerCells = splitRow(tableLines[0]!)
72
+ const bodyRows = tableLines.slice(2).map(splitRow)
73
+ const widths = computeWidths([headerCells, ...bodyRows])
74
+
75
+ const header = wrapCode(padRow(headerCells, widths))
76
+ const renderedRows = [`**${header}**`, ...bodyRows.map((cells) => wrapCode(padRow(cells, widths)))]
77
+ return renderedRows.join('\n')
78
+ }
79
+
80
+ function splitRow(row: string): string[] {
81
+ // Trim one optional leading/trailing pipe, then split on the rest. A trailing
82
+ // backslash before a pipe escapes it, but GFM table escaping is rare in agent
83
+ // output — we keep it simple and split on bare pipes.
84
+ let trimmed = row.trim()
85
+ if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
86
+ if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
87
+ return trimmed.split('|').map((cell) => cell.trim())
88
+ }
89
+
90
+ function computeWidths(rows: string[][]): number[] {
91
+ const widths: number[] = []
92
+ for (const row of rows) {
93
+ for (let c = 0; c < row.length; c++) {
94
+ const cellWidth = displayWidth(row[c]!)
95
+ if (widths[c] === undefined || cellWidth > widths[c]!) {
96
+ widths[c] = cellWidth
97
+ }
98
+ }
99
+ }
100
+ return widths
101
+ }
102
+
103
+ function padRow(cells: string[], widths: number[]): string {
104
+ const padded = widths.map((width, c) => padToWidth(cells[c] ?? '', width))
105
+ // Two spaces between columns keeps them visually distinct inside the
106
+ // monospaced span without a vertical-bar separator.
107
+ return padded.join(' ')
108
+ }
109
+
110
+ function padToWidth(cell: string, width: number): string {
111
+ const pad = width - displayWidth(cell)
112
+ return pad > 0 ? cell + ' '.repeat(pad) : cell
113
+ }
114
+
115
+ // Discord's monospaced inline-code font renders CJK ideographs, full-width
116
+ // punctuation, and most emoji at two columns, while combining/zero-width marks
117
+ // take none. `String.prototype.padEnd` counts UTF-16 code units, so padding by
118
+ // `.length` leaves wide-character tables visually ragged. We iterate by code
119
+ // point and sum per-glyph column widths so every cell pads to the same VISUAL
120
+ // width. The ranges below are the standard East-Asian-Wide / Wide blocks plus
121
+ // the common emoji planes; this is the same wcwidth approximation editors use.
122
+ export function displayWidth(text: string): number {
123
+ let width = 0
124
+ for (const ch of text) {
125
+ width += charWidth(ch.codePointAt(0)!)
126
+ }
127
+ return width
128
+ }
129
+
130
+ function charWidth(cp: number): number {
131
+ if (isZeroWidth(cp)) return 0
132
+ if (isWide(cp)) return 2
133
+ return 1
134
+ }
135
+
136
+ function isZeroWidth(cp: number): boolean {
137
+ return (
138
+ cp === 0x200b || // zero-width space
139
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
140
+ (cp >= 0x200c && cp <= 0x200f) || // ZWNJ/ZWJ/directional marks
141
+ (cp >= 0xfe00 && cp <= 0xfe0f) // variation selectors
142
+ )
143
+ }
144
+
145
+ function isWide(cp: number): boolean {
146
+ return (
147
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
148
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi
149
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols
150
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
151
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
152
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
153
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
154
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
155
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compatibility Forms
156
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms
157
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
158
+ (cp >= 0x2600 && cp <= 0x26ff) || // Miscellaneous Symbols (☀ ♻ ⚠ …)
159
+ (cp >= 0x2700 && cp <= 0x27bf) || // Dingbats (✅ ✔ ✨ ➡ …)
160
+ (cp >= 0x2b00 && cp <= 0x2bff) || // Misc Symbols and Arrows (⭐ …)
161
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji, symbols, pictographs
162
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ (supplementary ideographic)
163
+ )
164
+ }
165
+
166
+ // CommonMark inline code: the delimiter must be a backtick run LONGER than any
167
+ // run inside the content, otherwise an embedded `` ` `` (e.g. a cell holding
168
+ // `bun test`) closes the span early and corrupts the row. When the content
169
+ // begins or ends with a backtick, one space of padding is inserted on each side
170
+ // so the delimiter is not adjacent to a content backtick; CommonMark strips that
171
+ // single padding space on render, leaving our column widths intact.
172
+ function wrapCode(text: string): string {
173
+ const fence = '`'.repeat(longestBacktickRun(text) + 1)
174
+ const needsPad = text.startsWith('`') || text.endsWith('`')
175
+ const pad = needsPad ? ' ' : ''
176
+ return `${fence}${pad}${text}${pad}${fence}`
177
+ }
178
+
179
+ function longestBacktickRun(text: string): number {
180
+ let longest = 0
181
+ let run = 0
182
+ for (const ch of text) {
183
+ if (ch === '`') {
184
+ run++
185
+ if (run > longest) longest = run
186
+ } else {
187
+ run = 0
188
+ }
189
+ }
190
+ return longest
191
+ }
@@ -39,6 +39,7 @@ import {
39
39
  type InboundDropReason,
40
40
  renderPlaceholder,
41
41
  } from './discord-bot-classify'
42
+ import { convertDiscordTables } from './discord-bot-format'
42
43
  import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
43
44
  import { enrichDiscordMessageReferences } from './discord-bot-reference'
44
45
  import {
@@ -647,7 +648,7 @@ export function createOutboundCallback(deps: {
647
648
  if (msg.adapter !== 'discord-bot') {
648
649
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
649
650
  }
650
- const text = msg.text ?? ''
651
+ const text = convertDiscordTables(msg.text ?? '')
651
652
  const attachments = msg.attachments ?? []
652
653
  if (text === '' && attachments.length === 0) {
653
654
  return { ok: false, error: 'message has neither text nor attachments' }