typeclaw 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
package/src/channels/router.ts
CHANGED
|
@@ -78,6 +78,24 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
|
78
78
|
export const SESSION_IDLE_MS = 30 * 60 * 1000
|
|
79
79
|
export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
80
80
|
|
|
81
|
+
// Hard cap on tool-initiated outbound sends per (chat:thread) per turn.
|
|
82
|
+
// The original loop-incident emitted ~50 sends in one turn; even
|
|
83
|
+
// legitimate split replies rarely cross 8. 10 leaves headroom for
|
|
84
|
+
// genuine multi-part answers while definitively stopping runaway loops.
|
|
85
|
+
// Enforced inside router.send for `source: 'tool'` callers; system
|
|
86
|
+
// recovery paths (`source: 'system'`) bypass.
|
|
87
|
+
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
88
|
+
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
89
|
+
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
90
|
+
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
91
|
+
// threshold below escalates the per-send log to a warning.
|
|
92
|
+
export const SEND_RATE_WINDOW_MS = 5_000
|
|
93
|
+
// Above this in-window count, the per-send log line escalates to a
|
|
94
|
+
// `send_rate_warning` so a burst stands out in the log stream. Every
|
|
95
|
+
// send still emits a structured log line regardless of rate — this
|
|
96
|
+
// constant only controls when the warning marker appears.
|
|
97
|
+
export const SEND_RATE_WARN_THRESHOLD = 3
|
|
98
|
+
|
|
81
99
|
/**
|
|
82
100
|
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
83
101
|
* Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
|
|
@@ -244,6 +262,26 @@ type LiveSession = {
|
|
|
244
262
|
// router.send so the hint reflects the position of the about-to-happen send
|
|
245
263
|
// (n-th in a row), nudging the model to yield without forcing it to.
|
|
246
264
|
consecutiveSends: Map<string, number>
|
|
265
|
+
// Per-(chat:thread) text of the last reserved bot send. Set
|
|
266
|
+
// SYNCHRONOUSLY inside router.send before the outbound callback awaits,
|
|
267
|
+
// so two concurrent `router.send` calls for the same target cannot both
|
|
268
|
+
// pass the duplicate guard. Cleared on every new prompt batch (same
|
|
269
|
+
// lifecycle as `consecutiveSends`). The scope is "last 1 send within
|
|
270
|
+
// this turn" so legitimate multi-part replies (different bodies) and
|
|
271
|
+
// across-turn callbacks ("yes, I'm here" twice) are not blocked. Empty
|
|
272
|
+
// strings are normalized to undefined before storage so attachments-only
|
|
273
|
+
// sends never poison the tracker. The fuzzy-match upgrade is intentionally
|
|
274
|
+
// deferred — exact-match has zero false-positive risk by construction.
|
|
275
|
+
lastSentText: Map<string, string>
|
|
276
|
+
// Per-(chat:thread) ring of send timestamps (epoch ms) within the rolling
|
|
277
|
+
// SEND_RATE_WINDOW_MS window. Append-on-send, prune-on-read. Lifecycle is
|
|
278
|
+
// wall-clock (NOT cleared on new prompt batches) because rate is a
|
|
279
|
+
// property of the channel over time, not the agent's turn structure — a
|
|
280
|
+
// burst that straddles two adjacent turns is still a burst from the chat
|
|
281
|
+
// platform's POV. Telemetry-only today; the rate is logged when count
|
|
282
|
+
// crosses SEND_RATE_LOG_THRESHOLD so production data can inform a
|
|
283
|
+
// future hard cap without picking a threshold out of thin air.
|
|
284
|
+
sendTimestamps: Map<string, number[]>
|
|
247
285
|
successfulChannelSends: number
|
|
248
286
|
// Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
|
|
249
287
|
// above. Updated in route() on every engaged peer-bot inbound, reset on
|
|
@@ -264,15 +302,35 @@ type ChannelCommandContext = {
|
|
|
264
302
|
event: InboundMessage
|
|
265
303
|
}
|
|
266
304
|
|
|
305
|
+
export type SendSource = 'tool' | 'system'
|
|
306
|
+
|
|
307
|
+
export type SendOptions = {
|
|
308
|
+
source?: SendSource
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const DUPLICATE_SEND_ERROR =
|
|
312
|
+
'Duplicate not sent. Do not call channel_send/channel_reply again this turn. ' +
|
|
313
|
+
'End with NO_REPLY unless you have genuinely new, non-redundant information.'
|
|
314
|
+
|
|
315
|
+
export const TURN_CAP_ERROR =
|
|
316
|
+
`Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
|
|
317
|
+
'End your turn now. The user can prompt you again for more output.'
|
|
318
|
+
|
|
267
319
|
export type ChannelRouter = {
|
|
268
320
|
route: (event: InboundMessage) => Promise<void>
|
|
269
|
-
send: (msg: OutboundMessage) => Promise<SendResult>
|
|
321
|
+
send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
|
|
270
322
|
getConsecutiveSendCount: (target: {
|
|
271
323
|
adapter: ChannelKey['adapter']
|
|
272
324
|
workspace: string
|
|
273
325
|
chat: string
|
|
274
326
|
thread?: string | null
|
|
275
327
|
}) => number
|
|
328
|
+
getSendRate: (target: {
|
|
329
|
+
adapter: ChannelKey['adapter']
|
|
330
|
+
workspace: string
|
|
331
|
+
chat: string
|
|
332
|
+
thread?: string | null
|
|
333
|
+
}) => { count: number; windowMs: number }
|
|
276
334
|
registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
277
335
|
unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
278
336
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
@@ -719,6 +777,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
719
777
|
lastTurnAuthorIds: new Set(),
|
|
720
778
|
consecutiveAborts: 0,
|
|
721
779
|
consecutiveSends: new Map(),
|
|
780
|
+
lastSentText: new Map(),
|
|
781
|
+
sendTimestamps: new Map(),
|
|
722
782
|
successfulChannelSends: 0,
|
|
723
783
|
recentEngagedPeerBotTurns: [],
|
|
724
784
|
consecutiveEngagedPeerBotTurns: 0,
|
|
@@ -1011,7 +1071,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1011
1071
|
|
|
1012
1072
|
live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
|
|
1013
1073
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1014
|
-
if (batch.length > 0)
|
|
1074
|
+
if (batch.length > 0) {
|
|
1075
|
+
live.consecutiveSends.clear()
|
|
1076
|
+
live.lastSentText.clear()
|
|
1077
|
+
}
|
|
1015
1078
|
|
|
1016
1079
|
// Update the live origin holder so this turn's tool.before events
|
|
1017
1080
|
// carry the current actor's id. The DefaultResourceLoader still
|
|
@@ -1036,6 +1099,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1036
1099
|
} catch (err) {
|
|
1037
1100
|
logger.error(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
|
|
1038
1101
|
live.consecutiveSends.clear()
|
|
1102
|
+
live.lastSentText.clear()
|
|
1039
1103
|
} finally {
|
|
1040
1104
|
await fireSessionTurnEnd(live)
|
|
1041
1105
|
}
|
|
@@ -1108,13 +1172,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1108
1172
|
logger.info(
|
|
1109
1173
|
`[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
|
|
1110
1174
|
)
|
|
1111
|
-
await send(
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1175
|
+
await send(
|
|
1176
|
+
{
|
|
1177
|
+
adapter: event.adapter,
|
|
1178
|
+
workspace: event.workspace,
|
|
1179
|
+
chat: event.chat,
|
|
1180
|
+
thread: event.thread,
|
|
1181
|
+
text: outcome.reply,
|
|
1182
|
+
},
|
|
1183
|
+
{ source: 'system' },
|
|
1184
|
+
)
|
|
1118
1185
|
return
|
|
1119
1186
|
}
|
|
1120
1187
|
}
|
|
@@ -1421,10 +1488,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1421
1488
|
return lastError
|
|
1422
1489
|
}
|
|
1423
1490
|
|
|
1424
|
-
const send = async (msg: OutboundMessage): Promise<SendResult> => {
|
|
1491
|
+
const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
|
|
1492
|
+
const source: SendSource = opts?.source ?? 'tool'
|
|
1425
1493
|
const callbacks = outboundCallbacks.get(msg.adapter)
|
|
1426
1494
|
if (!callbacks || callbacks.size === 0) {
|
|
1427
|
-
return { ok: false, error: `no adapter registered for "${msg.adapter}"
|
|
1495
|
+
return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const keyId = channelKeyId({
|
|
1499
|
+
adapter: msg.adapter,
|
|
1500
|
+
workspace: msg.workspace,
|
|
1501
|
+
chat: msg.chat,
|
|
1502
|
+
thread: msg.thread ?? null,
|
|
1503
|
+
})
|
|
1504
|
+
const live = liveSessions.get(keyId)
|
|
1505
|
+
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1506
|
+
const text = normalizeSendText(msg.text)
|
|
1507
|
+
|
|
1508
|
+
// Central enforcement. Tool-initiated sends are subject to two policies:
|
|
1509
|
+
// a per-turn count cap (kills runaway loops regardless of content) and
|
|
1510
|
+
// an exact-duplicate guard (kills the byte-identical-spam sub-mode).
|
|
1511
|
+
// Both checks AND the state mutations they consult happen synchronously
|
|
1512
|
+
// before any `await`, so two concurrent `router.send` calls for the same
|
|
1513
|
+
// target (the parallel-tool-execution race) cannot both pass: the
|
|
1514
|
+
// second observer sees the first one's increment / lastSentText write.
|
|
1515
|
+
// System sources (validateChannelTurn recovery, role-claim reply) bypass
|
|
1516
|
+
// — those are one-shot paths the policy doesn't apply to.
|
|
1517
|
+
let priorLastSentText: string | undefined
|
|
1518
|
+
let reserved = false
|
|
1519
|
+
if (live && source === 'tool') {
|
|
1520
|
+
const currentCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1521
|
+
if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
|
|
1522
|
+
return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
|
|
1523
|
+
}
|
|
1524
|
+
if (text !== undefined && live.lastSentText.get(sendKey) === text) {
|
|
1525
|
+
return { ok: false, error: DUPLICATE_SEND_ERROR, code: 'duplicate' }
|
|
1526
|
+
}
|
|
1527
|
+
// Reserve the slot before awaiting. If the callback rejects we roll
|
|
1528
|
+
// back below; if it succeeds we keep the increment. The slot reserve
|
|
1529
|
+
// is what makes parallel tool calls safe. We also snapshot the prior
|
|
1530
|
+
// lastSentText so a transient delivery failure can be retried with
|
|
1531
|
+
// the same text — the dup-guard exists to stop runaway loops, not to
|
|
1532
|
+
// strand the model on a flaky adapter.
|
|
1533
|
+
priorLastSentText = live.lastSentText.get(sendKey)
|
|
1534
|
+
live.consecutiveSends.set(sendKey, currentCount + 1)
|
|
1535
|
+
if (text !== undefined) live.lastSentText.set(sendKey, text)
|
|
1536
|
+
reserved = true
|
|
1428
1537
|
}
|
|
1429
1538
|
|
|
1430
1539
|
// Snapshot the callbacks before iterating so a callback that mutates the
|
|
@@ -1443,16 +1552,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1443
1552
|
}
|
|
1444
1553
|
|
|
1445
1554
|
if (!delivered) {
|
|
1446
|
-
|
|
1555
|
+
// Roll back the slot reservation so a failed send doesn't burn cap
|
|
1556
|
+
// budget or poison the dup-guard. Restoring lastSentText to its
|
|
1557
|
+
// prior value (which may be undefined) lets a legitimate retry of
|
|
1558
|
+
// the same text succeed — the dup-guard is for loops, not flake.
|
|
1559
|
+
if (live && reserved) {
|
|
1560
|
+
const after = (live.consecutiveSends.get(sendKey) ?? 1) - 1
|
|
1561
|
+
if (after <= 0) live.consecutiveSends.delete(sendKey)
|
|
1562
|
+
else live.consecutiveSends.set(sendKey, after)
|
|
1563
|
+
if (priorLastSentText === undefined) live.lastSentText.delete(sendKey)
|
|
1564
|
+
else live.lastSentText.set(sendKey, priorLastSentText)
|
|
1565
|
+
}
|
|
1566
|
+
return { ok: false, error: lastError ?? 'no callback accepted the outbound', code: 'callback-rejected' }
|
|
1447
1567
|
}
|
|
1448
1568
|
|
|
1449
|
-
const keyId = channelKeyId({
|
|
1450
|
-
adapter: msg.adapter,
|
|
1451
|
-
workspace: msg.workspace,
|
|
1452
|
-
chat: msg.chat,
|
|
1453
|
-
thread: msg.thread ?? null,
|
|
1454
|
-
})
|
|
1455
|
-
const live = liveSessions.get(keyId)
|
|
1456
1569
|
if (live) {
|
|
1457
1570
|
live.successfulChannelSends++
|
|
1458
1571
|
// Don't stop the heartbeat here: the agent may still be mid-turn and
|
|
@@ -1477,8 +1590,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1477
1590
|
grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
|
|
1478
1591
|
}
|
|
1479
1592
|
}
|
|
1480
|
-
const
|
|
1481
|
-
live
|
|
1593
|
+
const turnCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1594
|
+
const rateCount = recordSendTimestamp(live, sendKey, now())
|
|
1595
|
+
const level = rateCount >= SEND_RATE_WARN_THRESHOLD ? 'warn' : 'info'
|
|
1596
|
+
const warn = rateCount >= SEND_RATE_WARN_THRESHOLD ? ' send_rate_warning' : ''
|
|
1597
|
+
const textLen = text !== undefined ? text.length : 0
|
|
1598
|
+
const fields = `source=${source} turn=${turnCount} rate=${rateCount}/${SEND_RATE_WINDOW_MS}ms text_len=${textLen}`
|
|
1599
|
+
logger[level](`[channels] ${live.keyId} send ${fields}${warn}`)
|
|
1482
1600
|
}
|
|
1483
1601
|
|
|
1484
1602
|
return { ok: true }
|
|
@@ -1498,13 +1616,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1498
1616
|
logger.warn(
|
|
1499
1617
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1500
1618
|
)
|
|
1501
|
-
const result = await send(
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1619
|
+
const result = await send(
|
|
1620
|
+
{
|
|
1621
|
+
adapter: live.key.adapter,
|
|
1622
|
+
workspace: live.key.workspace,
|
|
1623
|
+
chat: live.key.chat,
|
|
1624
|
+
thread: live.key.thread,
|
|
1625
|
+
text: assistantText,
|
|
1626
|
+
},
|
|
1627
|
+
{ source: 'system' },
|
|
1628
|
+
)
|
|
1508
1629
|
if (!result.ok) {
|
|
1509
1630
|
logger.warn(`[channels] ${live.keyId}: recovery send failed: ${result.error}`)
|
|
1510
1631
|
}
|
|
@@ -1527,6 +1648,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1527
1648
|
return live.consecutiveSends.get(consecutiveSendKey(target.chat, target.thread)) ?? 0
|
|
1528
1649
|
}
|
|
1529
1650
|
|
|
1651
|
+
const getSendRate = (target: {
|
|
1652
|
+
adapter: ChannelKey['adapter']
|
|
1653
|
+
workspace: string
|
|
1654
|
+
chat: string
|
|
1655
|
+
thread?: string | null
|
|
1656
|
+
}): { count: number; windowMs: number } => {
|
|
1657
|
+
const keyId = channelKeyId({
|
|
1658
|
+
adapter: target.adapter,
|
|
1659
|
+
workspace: target.workspace,
|
|
1660
|
+
chat: target.chat,
|
|
1661
|
+
thread: target.thread ?? null,
|
|
1662
|
+
})
|
|
1663
|
+
const live = liveSessions.get(keyId)
|
|
1664
|
+
if (!live) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
|
|
1665
|
+
const sendKey = consecutiveSendKey(target.chat, target.thread)
|
|
1666
|
+
const buf = live.sendTimestamps.get(sendKey)
|
|
1667
|
+
if (!buf || buf.length === 0) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
|
|
1668
|
+
const cutoff = now() - SEND_RATE_WINDOW_MS
|
|
1669
|
+
let i = 0
|
|
1670
|
+
while (i < buf.length && buf[i]! <= cutoff) i++
|
|
1671
|
+
if (i > 0) buf.splice(0, i)
|
|
1672
|
+
return { count: buf.length, windowMs: SEND_RATE_WINDOW_MS }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1530
1675
|
const tearDownLive = async (live: LiveSession): Promise<void> => {
|
|
1531
1676
|
live.destroyed = true
|
|
1532
1677
|
if (live.debounceTimer) clearTimeout(live.debounceTimer)
|
|
@@ -1585,6 +1730,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1585
1730
|
route,
|
|
1586
1731
|
send,
|
|
1587
1732
|
getConsecutiveSendCount,
|
|
1733
|
+
getSendRate,
|
|
1588
1734
|
registerOutbound,
|
|
1589
1735
|
unregisterOutbound,
|
|
1590
1736
|
registerTyping,
|
|
@@ -1759,6 +1905,26 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
|
|
|
1759
1905
|
return `${chat}:${thread ?? ''}`
|
|
1760
1906
|
}
|
|
1761
1907
|
|
|
1908
|
+
function normalizeSendText(text: string | undefined): string | undefined {
|
|
1909
|
+
if (text === undefined) return undefined
|
|
1910
|
+
if (text === '') return undefined
|
|
1911
|
+
return text
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function recordSendTimestamp(live: LiveSession, sendKey: string, ts: number): number {
|
|
1915
|
+
const buf = live.sendTimestamps.get(sendKey)
|
|
1916
|
+
const cutoff = ts - SEND_RATE_WINDOW_MS
|
|
1917
|
+
if (!buf) {
|
|
1918
|
+
live.sendTimestamps.set(sendKey, [ts])
|
|
1919
|
+
return 1
|
|
1920
|
+
}
|
|
1921
|
+
let i = 0
|
|
1922
|
+
while (i < buf.length && buf[i]! <= cutoff) i++
|
|
1923
|
+
if (i > 0) buf.splice(0, i)
|
|
1924
|
+
buf.push(ts)
|
|
1925
|
+
return buf.length
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1762
1928
|
function dmMembership(fetchedAt: number): MembershipCount {
|
|
1763
1929
|
return { humans: 1, bots: 1, fetchedAt, truncated: false }
|
|
1764
1930
|
}
|
package/src/channels/schema.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
|
|
3
|
+
export const ADAPTER_IDS = ['discord-bot', 'github', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
|
|
4
4
|
|
|
5
5
|
export type AdapterId = (typeof ADAPTER_IDS)[number]
|
|
6
6
|
|
|
@@ -99,6 +99,33 @@ const adapterSchema = z.object({
|
|
|
99
99
|
enabled: z.boolean().default(true),
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
103
|
+
'issue_comment.created',
|
|
104
|
+
'pull_request_review_comment.created',
|
|
105
|
+
'discussion_comment.created',
|
|
106
|
+
'issues.opened',
|
|
107
|
+
'pull_request.opened',
|
|
108
|
+
'discussion.created',
|
|
109
|
+
'pull_request_review.submitted',
|
|
110
|
+
] as const
|
|
111
|
+
|
|
112
|
+
const githubChannelSchema = adapterSchema.extend({
|
|
113
|
+
// Optional now (PR 2): when omitted and a `tunnels[]` entry with
|
|
114
|
+
// `for: { kind: 'channel', name: 'github' }` exists, the runtime resolves
|
|
115
|
+
// the URL from the tunnel manager via the adapter's `tunnelUrl` callback.
|
|
116
|
+
// The github adapter skips webhook registration when no effective URL is available.
|
|
117
|
+
webhookUrl: z.string().url().optional(),
|
|
118
|
+
webhookPort: z.number().int().positive().default(8975),
|
|
119
|
+
eventAllowlist: z.array(z.string()).default([...DEFAULT_GITHUB_EVENT_ALLOWLIST]),
|
|
120
|
+
// Repositories whose webhooks the adapter manages. Each entry is an
|
|
121
|
+
// `owner/name` slug. On adapter start(), TypeClaw registers a webhook
|
|
122
|
+
// pointing at webhookUrl for every repo here (idempotent: existing hooks
|
|
123
|
+
// at the same URL are updated). On stop(), every hook TypeClaw created
|
|
124
|
+
// this session is deleted so a restart with a different webhookUrl (e.g.
|
|
125
|
+
// a tunnel reassigning a URL) doesn't leave orphaned hooks on GitHub.
|
|
126
|
+
repos: z.array(z.string()).default([]),
|
|
127
|
+
})
|
|
128
|
+
|
|
102
129
|
// KakaoTalk uses the same shape as every other adapter. There used to be an
|
|
103
130
|
// `autoMarkRead` opt-in here; the adapter now fires a LOCO NOTIREAD ack on
|
|
104
131
|
// every inbound MSG event unconditionally (see kakaotalk.ts) so the sender's
|
|
@@ -112,6 +139,7 @@ const adapterSchema = z.object({
|
|
|
112
139
|
export const channelsSchema = z
|
|
113
140
|
.object({
|
|
114
141
|
'discord-bot': adapterSchema.optional(),
|
|
142
|
+
github: githubChannelSchema.optional(),
|
|
115
143
|
kakaotalk: adapterSchema.optional(),
|
|
116
144
|
'slack-bot': adapterSchema.optional(),
|
|
117
145
|
'telegram-bot': adapterSchema.optional(),
|
|
@@ -120,4 +148,6 @@ export const channelsSchema = z
|
|
|
120
148
|
|
|
121
149
|
export type EngagementConfig = z.infer<typeof engagementSchema>
|
|
122
150
|
export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
|
|
151
|
+
type ParsedGithubAdapterConfig = z.infer<typeof githubChannelSchema>
|
|
152
|
+
export type GithubAdapterConfig = ParsedGithubAdapterConfig
|
|
123
153
|
export type ChannelsConfig = z.infer<typeof channelsSchema>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Stream } from '@/stream'
|
|
2
|
+
import { isTunnelUrlChangedPayload } from '@/tunnels'
|
|
3
|
+
|
|
4
|
+
import type { AdapterId } from './schema'
|
|
5
|
+
|
|
6
|
+
export type TunnelBridgeLogger = {
|
|
7
|
+
info: (msg: string) => void
|
|
8
|
+
warn: (msg: string) => void
|
|
9
|
+
error: (msg: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TunnelBridgeChannelManager = {
|
|
13
|
+
restartAdapter: (name: AdapterId) => Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TunnelBridgeOptions = {
|
|
17
|
+
stream: Stream
|
|
18
|
+
channelManager: TunnelBridgeChannelManager
|
|
19
|
+
logger?: TunnelBridgeLogger
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TunnelBridge = {
|
|
23
|
+
stop: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const consoleLogger: TunnelBridgeLogger = {
|
|
27
|
+
info: (msg) => console.log(msg),
|
|
28
|
+
warn: (msg) => console.warn(msg),
|
|
29
|
+
error: (msg) => console.error(msg),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createTunnelBridge(options: TunnelBridgeOptions): TunnelBridge {
|
|
33
|
+
const logger = options.logger ?? consoleLogger
|
|
34
|
+
// Subscribe synchronously; run/index.ts must create this bridge before
|
|
35
|
+
// tunnelManager.start() so an initial provider URL broadcast cannot be missed.
|
|
36
|
+
const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
37
|
+
const payload = msg.payload
|
|
38
|
+
if (!isTunnelUrlChangedPayload(payload)) return
|
|
39
|
+
if (payload.for.kind !== 'channel') return
|
|
40
|
+
const name = (payload.for as { name?: unknown }).name
|
|
41
|
+
if (typeof name !== 'string') return
|
|
42
|
+
logger.info(`[tunnels] ${name} URL → restarting adapter`)
|
|
43
|
+
void options.channelManager.restartAdapter(name as AdapterId).catch((err: unknown) => {
|
|
44
|
+
logger.error(`[tunnels] failed to restart ${name} adapter: ${err instanceof Error ? err.message : String(err)}`)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
stop: unsubscribe,
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/channels/types.ts
CHANGED
|
@@ -84,7 +84,9 @@ export type OutboundMessage = {
|
|
|
84
84
|
attachments?: OutboundAttachment[]
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export type
|
|
87
|
+
export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected'
|
|
88
|
+
|
|
89
|
+
export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
|
|
88
90
|
|
|
89
91
|
export type OutboundCallback = (msg: OutboundMessage) => Promise<SendResult>
|
|
90
92
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single source of truth for the top-level `typeclaw` subcommands that the
|
|
2
|
+
// CLI dispatches via citty. Plugin commands MUST NOT shadow these names.
|
|
3
|
+
// `src/cli/index.ts` consumes this for argv interception; `src/plugin/registry.ts`
|
|
4
|
+
// consumes it to reject plugin commands that collide.
|
|
5
|
+
export const BUILTIN_COMMAND_NAMES = [
|
|
6
|
+
'init',
|
|
7
|
+
'run',
|
|
8
|
+
'tui',
|
|
9
|
+
'start',
|
|
10
|
+
'stop',
|
|
11
|
+
'restart',
|
|
12
|
+
'status',
|
|
13
|
+
'reload',
|
|
14
|
+
'logs',
|
|
15
|
+
'shell',
|
|
16
|
+
'compose',
|
|
17
|
+
'channel',
|
|
18
|
+
'cron',
|
|
19
|
+
'tunnel',
|
|
20
|
+
'role',
|
|
21
|
+
'provider',
|
|
22
|
+
'model',
|
|
23
|
+
'doctor',
|
|
24
|
+
'usage',
|
|
25
|
+
'_hostd',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
export type BuiltinCommandName = (typeof BUILTIN_COMMAND_NAMES)[number]
|