typeclaw 0.9.2 → 0.10.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 +2 -2
- package/src/agent/index.ts +9 -7
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/router.ts +126 -4
- package/src/channels/schema.ts +21 -0
- package/src/cli/cron.ts +1 -1
- package/src/cli/inspect.ts +105 -12
- package/src/cli/role.ts +2 -2
- package/src/config/providers.ts +18 -0
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/gitignore.ts +8 -0
- package/src/inspect/index.ts +42 -5
- package/src/inspect/loop.ts +20 -0
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/typeclaw.schema.json +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.
|
|
49
|
+
"agent-messenger": "2.18.0",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
package/src/agent/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent
|
|
|
8
8
|
import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
9
9
|
import type { ChannelRouter } from '@/channels/router'
|
|
10
10
|
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
11
|
-
import { providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
11
|
+
import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
12
12
|
import type { PermissionService } from '@/permissions'
|
|
13
13
|
import type {
|
|
14
14
|
BuiltinToolRef,
|
|
@@ -341,6 +341,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
341
341
|
: customToolsPreBudget
|
|
342
342
|
|
|
343
343
|
const model = resolveModel(activeRef)
|
|
344
|
+
const thinkingLevel = defaultThinkingLevelForRef(activeRef)
|
|
344
345
|
const { session } = await createAgentSession({
|
|
345
346
|
model,
|
|
346
347
|
sessionManager,
|
|
@@ -350,6 +351,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
350
351
|
resourceLoader,
|
|
351
352
|
...(tools ? { tools } : {}),
|
|
352
353
|
customTools,
|
|
354
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
353
355
|
})
|
|
354
356
|
|
|
355
357
|
// Re-narrow the active tool set after `createAgentSession`. pi 0.67.3's
|
|
@@ -890,12 +892,12 @@ function resolveRoleContext(
|
|
|
890
892
|
): SessionRoleContext | undefined {
|
|
891
893
|
if (permissions === undefined) return undefined
|
|
892
894
|
const described = permissions.describe(origin)
|
|
893
|
-
// TUI
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
895
|
+
// TUI resolves to `owner` because the built-in `owner.match = [tui]` is
|
|
896
|
+
// walked first under severity-then-declaration ordering AND is always
|
|
897
|
+
// appended (not replaced) by user-declared `roles.owner.match[]`. We skip
|
|
898
|
+
// the role block in that case to save tokens on every interactive
|
|
899
|
+
// session. The guard remains here as defense-in-depth in case a future
|
|
900
|
+
// change ever makes TUI resolve to something other than owner.
|
|
899
901
|
if (origin.kind === 'tui' && described.role === 'owner') return undefined
|
|
900
902
|
return described
|
|
901
903
|
}
|
|
@@ -49,22 +49,23 @@ type PerGuardSecurityPermission = Exclude<
|
|
|
49
49
|
// not a silent fallback.
|
|
50
50
|
const BYPASS_ROLE_HINT = {
|
|
51
51
|
[SECURITY_PERMISSIONS.bypassSecretExfilBash]:
|
|
52
|
-
'
|
|
52
|
+
'owner and trusted have it by default (medium tier); member and guest do not. Operators can grant `security.bypass.secretExfilBash` explicitly in roles.<role>.permissions[] to widen.',
|
|
53
53
|
[SECURITY_PERMISSIONS.bypassGitExfil]:
|
|
54
|
-
'
|
|
54
|
+
'owner and trusted have it by default (medium tier); member and guest do not. The audience-leak surface for git lives in `gitRemoteTainted` (high tier, owner-only) — pushing to an attacker-retargeted remote is still blocked for trusted by the two-step taint defense.',
|
|
55
55
|
[SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
|
|
56
|
-
'
|
|
57
|
-
[SECURITY_PERMISSIONS.bypassSecretExfilRead]:
|
|
58
|
-
|
|
59
|
-
[SECURITY_PERMISSIONS.
|
|
60
|
-
[SECURITY_PERMISSIONS.
|
|
61
|
-
'
|
|
56
|
+
'only owner has it by default (high tier). The two-step taint defense (recorder + checker) still fires whenever the actor lacks `security.bypass.gitRemoteTainted`, including across owner-granted gitExfil bypasses.',
|
|
57
|
+
[SECURITY_PERMISSIONS.bypassSecretExfilRead]:
|
|
58
|
+
'owner and trusted have it by default (medium tier); member and guest do not.',
|
|
59
|
+
[SECURITY_PERMISSIONS.bypassSsrf]: 'owner and trusted have it by default (medium tier); member and guest do not.',
|
|
60
|
+
[SECURITY_PERMISSIONS.bypassSessionSearchSecrets]:
|
|
61
|
+
'owner and trusted have it by default (medium tier); member and guest do not.',
|
|
62
|
+
[SECURITY_PERMISSIONS.bypassSystemPromptLeak]: 'only owner has it by default (high tier).',
|
|
62
63
|
[SECURITY_PERMISSIONS.bypassOutboundSecret]:
|
|
63
|
-
'
|
|
64
|
+
'only owner has it by default (high tier). The audience-leak risk: an owner-permissioned channel author can silently include credentials in outbound messages. Operators who match owner to a channel author should narrow that match or remove owner from `roles.owner.permissions[]` for those origins.',
|
|
64
65
|
[SECURITY_PERMISSIONS.bypassRolePromotion]:
|
|
65
|
-
'
|
|
66
|
+
'owner and trusted have it by default (medium tier); member and guest do not. The privilege-escalation defense for trusted now depends on operator review of `typeclaw.json` backup commits — `roles` is restart-required, so the operator has wall-clock time to revert before the new role table takes effect. Operators who do not review can re-tighten by replacing `roles.trusted.permissions[]` with an explicit list that omits `security.bypass.medium`.',
|
|
66
67
|
[SECURITY_PERMISSIONS.bypassCronPromotion]:
|
|
67
|
-
'
|
|
68
|
+
'owner and trusted have it by default (medium tier); member and guest do not. Same shape as rolePromotion but deferred: a new cron job (or a changed scheduledByRole) fires at schedule-time as the stamped role. The operator-review window between write and execution is the trusted-tier defense.',
|
|
68
69
|
} as const satisfies Record<PerGuardSecurityPermission, string>
|
|
69
70
|
|
|
70
71
|
function withPermissionHint(
|
|
@@ -83,12 +84,13 @@ function withPermissionHint(
|
|
|
83
84
|
|
|
84
85
|
export default definePlugin({
|
|
85
86
|
permissions: Object.values(SECURITY_PERMISSIONS),
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
87
|
+
// No wildcard exclusions: owner bypasses every security tier by default
|
|
88
|
+
// under the role-tower model. `BUILTIN_ROLES.owner.permissions` carries
|
|
89
|
+
// `security.bypass.{low,medium,high}` explicitly; the wildcard sentinel
|
|
90
|
+
// additionally fans out to every per-guard string (including high-tier
|
|
91
|
+
// ones). The owner-in-public-channel defense now lives in
|
|
92
|
+
// `roles.owner.match[]` discipline, not in the language defaults.
|
|
93
|
+
ownerWildcardExclusions: [],
|
|
92
94
|
plugin: async (ctx) => ({
|
|
93
95
|
hooks: {
|
|
94
96
|
'session.prompt': async (event) => {
|
|
@@ -37,16 +37,17 @@ export const SEVERITY_PERMISSION: Record<SecuritySeverity, string> = {
|
|
|
37
37
|
high: SECURITY_PERMISSIONS.bypassHigh,
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Per-guard permission strings whose guards are classified `high`.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
40
|
+
// Per-guard permission strings whose guards are classified `high`.
|
|
41
|
+
// Plumbed through to the owner-wildcard expander's `ownerWildcardExclusions`
|
|
42
|
+
// parameter at boot; the bundled security plugin currently passes `[]` so
|
|
43
|
+
// owner DOES auto-bypass every high-tier per-guard string, but third-party
|
|
44
|
+
// plugins (or a future tightening of the bundled defaults) can use this
|
|
45
|
+
// constant to exclude high-tier strings from the wildcard expansion.
|
|
46
|
+
// Keep this list in sync with the `'high'` classifications in
|
|
47
|
+
// `policies/*.ts` — the drift-guard test in `permissions.test.ts` will
|
|
48
|
+
// fail if a guard's severity constant disagrees with its membership here.
|
|
45
49
|
export const HIGH_TIER_PER_GUARD_PERMISSIONS: readonly string[] = [
|
|
46
|
-
SECURITY_PERMISSIONS.bypassGitExfil,
|
|
47
50
|
SECURITY_PERMISSIONS.bypassGitRemoteTainted,
|
|
48
51
|
SECURITY_PERMISSIONS.bypassOutboundSecret,
|
|
49
52
|
SECURITY_PERMISSIONS.bypassSystemPromptLeak,
|
|
50
|
-
SECURITY_PERMISSIONS.bypassRolePromotion,
|
|
51
|
-
SECURITY_PERMISSIONS.bypassCronPromotion,
|
|
52
53
|
]
|
|
@@ -7,8 +7,23 @@ import type { SecuritySeverity } from '../permissions'
|
|
|
7
7
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
8
8
|
|
|
9
9
|
export const GUARD_CRON_PROMOTION = 'cronPromotion'
|
|
10
|
-
// Classified `
|
|
11
|
-
// `rolePromotion
|
|
10
|
+
// Classified `medium` (silent-attack axis). Originally `high`; reclassified
|
|
11
|
+
// for the same reason as `rolePromotion`: the deferred-execution surface
|
|
12
|
+
// is still operator-reviewable before the job fires. `cron.json` is
|
|
13
|
+
// force-committed by the auto-backup plugin, the change appears in
|
|
14
|
+
// `git log` and backup commits, and the cron consumer dispatches by
|
|
15
|
+
// schedule — there is wall-clock time between the privileged write and
|
|
16
|
+
// the privileged execution during which the operator can revert or
|
|
17
|
+
// disable. Bypass produces operator-reviewable state, not direct
|
|
18
|
+
// audience-leak.
|
|
19
|
+
//
|
|
20
|
+
// Net effect on the role-tower model: owner and trusted both bypass
|
|
21
|
+
// without ack; member and guest still get blocked. The defense for
|
|
22
|
+
// trusted depends on backup-commit review discipline — same tradeoff
|
|
23
|
+
// as `rolePromotion`. Operators who want to keep this at high for
|
|
24
|
+
// trusted can subtract: replace `roles.trusted.permissions[]` with an
|
|
25
|
+
// explicit list that omits `security.bypass.medium`, then add narrower
|
|
26
|
+
// per-guard medium grants as needed.
|
|
12
27
|
//
|
|
13
28
|
// Cron is the deferred-execution sibling of `roles`. Every cron job
|
|
14
29
|
// carries a `scheduledByRole` field that the runtime stamps into the
|
|
@@ -17,12 +32,14 @@ export const GUARD_CRON_PROMOTION = 'cronPromotion'
|
|
|
17
32
|
// table"). The `parseCronFile` boot gate rejects entries without
|
|
18
33
|
// `scheduledByRole`, but it accepts any role name the file declares.
|
|
19
34
|
//
|
|
20
|
-
// Concrete breach pattern
|
|
21
|
-
// `cron.json` authors a brand-new job with
|
|
22
|
-
// and a prompt that does whatever the
|
|
23
|
-
// running as owner. The cron consumer
|
|
24
|
-
// session resolves to `owner` because
|
|
25
|
-
// table. The agent has laundered
|
|
35
|
+
// Concrete breach pattern blocked at `medium`: a `member`-role agent
|
|
36
|
+
// that can `write` `cron.json` authors a brand-new job with
|
|
37
|
+
// `"scheduledByRole": "owner"` and a prompt that does whatever the
|
|
38
|
+
// agent's tool surface allows when running as owner. The cron consumer
|
|
39
|
+
// fires it on schedule; the firing session resolves to `owner` because
|
|
40
|
+
// that role name exists in the role table. The agent has laundered
|
|
41
|
+
// itself into owner via the schedule. This guard blocks the first step
|
|
42
|
+
// — member does not carry `bypass.medium`.
|
|
26
43
|
//
|
|
27
44
|
// Same two-step shape as `gitRemoteTainted`: "do a privileged write
|
|
28
45
|
// now, run the privileged thing later." This guard blocks the first
|
|
@@ -66,7 +83,7 @@ export const GUARD_CRON_PROMOTION = 'cronPromotion'
|
|
|
66
83
|
// is treated as new and flagged. The only false positive is "operator
|
|
67
84
|
// authored a fresh `cron.json` with privileged jobs," which they
|
|
68
85
|
// acknowledge in the same call.
|
|
69
|
-
export const GUARD_CRON_PROMOTION_SEVERITY: SecuritySeverity = '
|
|
86
|
+
export const GUARD_CRON_PROMOTION_SEVERITY: SecuritySeverity = 'medium'
|
|
70
87
|
|
|
71
88
|
export type CronPromotionFinding =
|
|
72
89
|
| { kind: 'job-added'; id: string; scheduledByRole: string }
|
|
@@ -3,24 +3,32 @@ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../
|
|
|
3
3
|
import { getRemoteTaint, recordRemoteTaint } from './remote-taint-state'
|
|
4
4
|
|
|
5
5
|
export const GUARD_GIT_EXFIL = 'gitExfil'
|
|
6
|
-
// Classified `
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
6
|
+
// Classified `medium` (silent-attack axis). Originally `high`; reclassified
|
|
7
|
+
// because the actual audience-leak surface for `git push` lives in
|
|
8
|
+
// `gitRemoteTainted`, not here. A `git push` to a CLEAN, operator-configured
|
|
9
|
+
// remote is not audience-leak — the audience (the remote git host) was
|
|
10
|
+
// chosen by the operator and is inside their perimeter. The breach pattern
|
|
11
|
+
// PR #134 was written for (re-point origin to attacker URL, then push) is
|
|
12
|
+
// gated by `gitRemoteTainted` (still high). The recorder-vs-checker split
|
|
13
|
+
// is what makes this reclassification safe: the recorder fires for any
|
|
14
|
+
// actor who can run a `git remote set-url` (per-guard bypass OR the
|
|
15
|
+
// medium-tier permission via the OR check), so trusted's first-step
|
|
16
|
+
// set-url still records taint and the second-step push still gets caught
|
|
17
|
+
// by `gitRemoteTainted` even though trusted no longer needs to ack the
|
|
18
|
+
// push itself. Net effect: trusted users can push to remotes the operator
|
|
19
|
+
// configured without per-call acks, but cannot retarget-and-push.
|
|
20
|
+
export const GUARD_GIT_EXFIL_SEVERITY: SecuritySeverity = 'medium'
|
|
15
21
|
export const GUARD_GIT_REMOTE_TAINTED = 'gitRemoteTainted'
|
|
16
|
-
// Classified `high` (audience-leak axis):
|
|
17
|
-
//
|
|
22
|
+
// Classified `high` (audience-leak axis): the actual audience-leak gate
|
|
23
|
+
// for git. A push after a mid-session `git remote set-url` to an
|
|
18
24
|
// attacker-controlled URL is exactly the breach pattern that motivated
|
|
19
|
-
// the entire security plugin per PR #134.
|
|
25
|
+
// the entire security plugin per PR #134. Stays high regardless of how
|
|
26
|
+
// `gitExfil` is classified — the two are independent per-guard strings
|
|
27
|
+
// AND independent tier classifications. The recorder-vs-checker split
|
|
20
28
|
// (see comment on recordGitRemoteTaintIfAny below) is still load-bearing:
|
|
21
|
-
// the recorder fires for anyone who can run the underlying
|
|
22
|
-
//
|
|
23
|
-
//
|
|
29
|
+
// the recorder fires for anyone who can run the underlying `set-url`
|
|
30
|
+
// command (ack, per-guard `bypassGitExfil`, OR the medium-tier permission
|
|
31
|
+
// — which now includes trusted by default), so the second-step taint
|
|
24
32
|
// check still fires on the eventual push.
|
|
25
33
|
export const GUARD_GIT_REMOTE_TAINTED_SEVERITY: SecuritySeverity = 'high'
|
|
26
34
|
|
|
@@ -8,25 +8,32 @@ import type { SecuritySeverity } from '../permissions'
|
|
|
8
8
|
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
9
9
|
|
|
10
10
|
export const GUARD_ROLE_PROMOTION = 'rolePromotion'
|
|
11
|
-
// Classified `
|
|
11
|
+
// Classified `medium` (silent-attack axis). Originally `high`; reclassified
|
|
12
|
+
// because the privilege escalation does NOT take effect until the operator
|
|
13
|
+
// reloads or restarts — `roles` is `restart-required` in FIELD_EFFECTS, and
|
|
14
|
+
// even the `match`-only path that's classified `applied` writes through
|
|
15
|
+
// `typeclaw.json` which is force-committed by the auto-backup plugin on
|
|
16
|
+
// idle. The operator sees the file change in `git log`, in `typeclaw reload`
|
|
17
|
+
// output, and in their backup commits BEFORE the new role mapping takes
|
|
18
|
+
// effect. There is an operator-visible step between bypass and breach,
|
|
19
|
+
// which puts this guard squarely on the medium axis: bypass produces
|
|
20
|
+
// attacker-favorable state in operator-reviewable surface, not direct
|
|
21
|
+
// audience-leak.
|
|
12
22
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
23
|
+
// Net effect on the role-tower model: owner and trusted both bypass without
|
|
24
|
+
// ack; member and guest still get blocked. The defense for trusted now
|
|
25
|
+
// depends on operator config-review discipline — if backup commits are
|
|
26
|
+
// reviewed and `typeclaw reload` output is read before applying, a
|
|
27
|
+
// trusted-laundered role promotion is caught before it fires. Operators
|
|
28
|
+
// who do not review can re-tighten by adding `security.bypass.rolePromotion`
|
|
29
|
+
// to `trusted.permissions[]` as an explicit subtraction (replace the
|
|
30
|
+
// default tier grant with a narrower list) — see typeclaw-permissions skill.
|
|
21
31
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// generalizes from "post credentials" to "promote the asker". No role
|
|
28
|
-
// auto-bypasses; per-call ack or an explicit `security.bypass.rolePromotion`
|
|
29
|
-
// grant is required.
|
|
32
|
+
// Breach pattern blocked at `medium`: a `member`-role speaker in a chat
|
|
33
|
+
// asks "promote me to admin"; the agent edits typeclaw.json; the change is
|
|
34
|
+
// schema-valid, managedConfig accepts it, nonWorkspaceWrite allowlists
|
|
35
|
+
// typeclaw.json — but this guard still blocks because member does not
|
|
36
|
+
// carry `bypass.medium` by default.
|
|
30
37
|
//
|
|
31
38
|
// What counts as a promotion (any of):
|
|
32
39
|
// 1. A role's `permissions[]` gained an entry.
|
|
@@ -58,7 +65,7 @@ export const GUARD_ROLE_PROMOTION = 'rolePromotion'
|
|
|
58
65
|
// agent cannot start a claim, only consume one whose code the
|
|
59
66
|
// operator already broadcast. That makes the bypass intentionally
|
|
60
67
|
// out-of-band — do not extend this guard to cover it.
|
|
61
|
-
export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = '
|
|
68
|
+
export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = 'medium'
|
|
62
69
|
|
|
63
70
|
export type RolePromotionFinding = {
|
|
64
71
|
role: string
|
package/src/channels/router.ts
CHANGED
|
@@ -29,7 +29,11 @@ import {
|
|
|
29
29
|
saveChannelSessions,
|
|
30
30
|
type ChannelSessionRecord,
|
|
31
31
|
} from './persistence'
|
|
32
|
-
import
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS,
|
|
34
|
+
QUOTED_REPLY_EXCERPT_MAX_CHARS,
|
|
35
|
+
type ChannelAdapterConfig,
|
|
36
|
+
} from './schema'
|
|
33
37
|
import type {
|
|
34
38
|
ChannelHistoryMessage,
|
|
35
39
|
ChannelKey,
|
|
@@ -302,6 +306,15 @@ type LiveSession = {
|
|
|
302
306
|
// future hard cap without picking a threshold out of thin air.
|
|
303
307
|
sendTimestamps: Map<string, number[]>
|
|
304
308
|
successfulChannelSends: number
|
|
309
|
+
// Captured by drain() at batch dequeue; read+cleared by send() on the
|
|
310
|
+
// first tool-source send of the turn. The anchor decision (delay
|
|
311
|
+
// threshold + intervening-observed check) is evaluated at SEND time
|
|
312
|
+
// against this snapshot — not at drain time — because the relevant
|
|
313
|
+
// signal is how long the user waited from inbound to seeing the reply
|
|
314
|
+
// land, which only the send-side clock knows. Cleared after first
|
|
315
|
+
// consumption so multi-part replies anchor only on chunk 1. A new
|
|
316
|
+
// batch overwrites unconditionally.
|
|
317
|
+
pendingQuoteCandidate: QuoteAnchorCandidate | null
|
|
305
318
|
// Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
|
|
306
319
|
// above. Updated in route() on every engaged peer-bot inbound, reset on
|
|
307
320
|
// any human inbound. The two axes (window ring buffer + since-human
|
|
@@ -913,6 +926,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
913
926
|
lastSentText: new Map(),
|
|
914
927
|
sendTimestamps: new Map(),
|
|
915
928
|
successfulChannelSends: 0,
|
|
929
|
+
pendingQuoteCandidate: null,
|
|
916
930
|
recentEngagedPeerBotTurns: [],
|
|
917
931
|
consecutiveEngagedPeerBotTurns: 0,
|
|
918
932
|
loopGuardActive: false,
|
|
@@ -1213,6 +1227,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1213
1227
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1214
1228
|
live.consecutiveSends.clear()
|
|
1215
1229
|
live.lastSentText.clear()
|
|
1230
|
+
live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
|
|
1216
1231
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1217
1232
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1218
1233
|
// restore the author identity from the prior turn so author-
|
|
@@ -1340,10 +1355,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1340
1355
|
|
|
1341
1356
|
// Role-claim intercept runs BEFORE the channel.respond gate so the
|
|
1342
1357
|
// operator can bootstrap permissions on a fresh agent that has no
|
|
1343
|
-
// role match rules yet. Cheap pre-check:
|
|
1344
|
-
// a `claim-` prefix
|
|
1358
|
+
// role match rules yet. Cheap pre-check: any inbound whose text
|
|
1359
|
+
// contains a `claim-` prefix is a candidate, and only when a handler
|
|
1345
1360
|
// is registered. Everything else falls straight through to the gate.
|
|
1346
|
-
|
|
1361
|
+
// Claims are accepted from any chat (DM, group, thread) because the
|
|
1362
|
+
// resulting match rule is platform-wide + author-scoped — see
|
|
1363
|
+
// src/role-claim/match-rule.ts.
|
|
1364
|
+
if (claimHandler !== undefined && extractClaimCode(event.text) !== null) {
|
|
1347
1365
|
const outcome = await claimHandler({
|
|
1348
1366
|
adapter: event.adapter,
|
|
1349
1367
|
workspace: event.workspace,
|
|
@@ -1692,6 +1710,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1692
1710
|
})
|
|
1693
1711
|
const live = liveSessions.get(keyId)
|
|
1694
1712
|
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1713
|
+
// Tool-source sends consume the captured quote candidate exactly
|
|
1714
|
+
// once per turn — the decision (delay threshold + intervening-
|
|
1715
|
+
// observed check) runs HERE against the live clock so the relevant
|
|
1716
|
+
// signal is real wall-time between inbound and reply landing, not
|
|
1717
|
+
// drain-vs-send timing artifacts. System sources (recovery, role-
|
|
1718
|
+
// claim) skip so they can't accidentally swallow the candidate
|
|
1719
|
+
// before the model's own first reply lands. Even when the decision
|
|
1720
|
+
// returns null (delay below threshold, nothing intervened), the
|
|
1721
|
+
// candidate is cleared — a multi-part reply that crosses the
|
|
1722
|
+
// threshold mid-flight must not retroactively anchor chunk 2.
|
|
1723
|
+
if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
|
|
1724
|
+
const anchor = decideQuoteAnchor(live.pendingQuoteCandidate, now(), options.configForAdapter(msg.adapter))
|
|
1725
|
+
if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
|
|
1726
|
+
live.pendingQuoteCandidate = null
|
|
1727
|
+
}
|
|
1695
1728
|
const text = normalizeSendText(msg.text)
|
|
1696
1729
|
|
|
1697
1730
|
// Central enforcement. Tool-initiated sends are subject to two policies:
|
|
@@ -2229,6 +2262,95 @@ function formatAuthorLine(
|
|
|
2229
2262
|
return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
|
|
2230
2263
|
}
|
|
2231
2264
|
|
|
2265
|
+
export type QuoteAnchorSource = {
|
|
2266
|
+
authorName: string
|
|
2267
|
+
text: string
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// Renders the single-line `> @name: excerpt` blockquote prepended to
|
|
2271
|
+
// outbound replies when the router decides the reply needs an anchor.
|
|
2272
|
+
// Collapses newlines to spaces so a multi-line user message renders on
|
|
2273
|
+
// one quoted line (markdown blockquote semantics: a blank line ends the
|
|
2274
|
+
// quote, and `> foo\nbar` would split the quote and the reply); strips
|
|
2275
|
+
// existing leading `>` so a quote-of-a-quote stays single-level. Empty
|
|
2276
|
+
// inbound text (mention-only inbounds like `<@bot>`) falls back to a
|
|
2277
|
+
// generic marker so the user still sees "the bot saw your ping".
|
|
2278
|
+
export function renderQuoteAnchor(source: QuoteAnchorSource): string {
|
|
2279
|
+
const collapsed = source.text
|
|
2280
|
+
.replace(/\s+/g, ' ')
|
|
2281
|
+
.replace(/^>+\s*/, '')
|
|
2282
|
+
.trim()
|
|
2283
|
+
const excerpt =
|
|
2284
|
+
collapsed === ''
|
|
2285
|
+
? '(no text)'
|
|
2286
|
+
: collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
|
|
2287
|
+
? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
|
|
2288
|
+
: collapsed
|
|
2289
|
+
return `> @${source.authorName}: ${excerpt}`
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
|
|
2293
|
+
const anchor = renderQuoteAnchor(source)
|
|
2294
|
+
if (replyText === '') return anchor
|
|
2295
|
+
return `${anchor}\n${replyText}`
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
type QuoteAnchorBatchEntry = {
|
|
2299
|
+
text: string
|
|
2300
|
+
authorName: string
|
|
2301
|
+
authorIsBot: boolean
|
|
2302
|
+
receivedAt: number
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
type QuoteAnchorObservedEntry = {
|
|
2306
|
+
receivedAt: number
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
export type QuoteAnchorCandidate = {
|
|
2310
|
+
source: QuoteAnchorSource
|
|
2311
|
+
primaryReceivedAt: number
|
|
2312
|
+
hadInterveningObserved: boolean
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// Snapshot the primary inbound + observed-buffer state at drain time so
|
|
2316
|
+
// the send-side decision has the data it needs without holding a
|
|
2317
|
+
// reference to the batch arrays. Returns null when there's nothing
|
|
2318
|
+
// anchorable (empty batch, primary is a bot).
|
|
2319
|
+
export function captureQuoteCandidate(
|
|
2320
|
+
batch: readonly QuoteAnchorBatchEntry[],
|
|
2321
|
+
observed: readonly QuoteAnchorObservedEntry[],
|
|
2322
|
+
): QuoteAnchorCandidate | null {
|
|
2323
|
+
if (batch.length === 0) return null
|
|
2324
|
+
const primary = batch[batch.length - 1]!
|
|
2325
|
+
if (primary.authorIsBot) return null
|
|
2326
|
+
const hadInterveningObserved = observed.some((o) => o.receivedAt >= primary.receivedAt)
|
|
2327
|
+
return {
|
|
2328
|
+
source: { authorName: primary.authorName, text: primary.text },
|
|
2329
|
+
primaryReceivedAt: primary.receivedAt,
|
|
2330
|
+
hadInterveningObserved,
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Send-time decision: given a captured candidate and the current clock,
|
|
2335
|
+
// returns the source to anchor against or null. Skips when:
|
|
2336
|
+
// - quotedReply is disabled in config
|
|
2337
|
+
// - delay is under threshold AND no observed messages came between
|
|
2338
|
+
// primary inbound and now (the "felt instantaneous" path)
|
|
2339
|
+
// A null candidate (no batch yet, or batch was bot-only) always skips.
|
|
2340
|
+
export function decideQuoteAnchor(
|
|
2341
|
+
candidate: QuoteAnchorCandidate | null,
|
|
2342
|
+
nowMs: number,
|
|
2343
|
+
adapterConfig: ChannelAdapterConfig | undefined,
|
|
2344
|
+
): QuoteAnchorSource | null {
|
|
2345
|
+
if (candidate === null) return null
|
|
2346
|
+
const config = adapterConfig?.quotedReply
|
|
2347
|
+
if (config !== undefined && config.enabled === false) return null
|
|
2348
|
+
const threshold = config?.queueDelayMs ?? DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS
|
|
2349
|
+
const delay = nowMs - candidate.primaryReceivedAt
|
|
2350
|
+
if (delay < threshold && !candidate.hadInterveningObserved) return null
|
|
2351
|
+
return candidate.source
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2232
2354
|
type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
|
|
2233
2355
|
|
|
2234
2356
|
export function sliceHeadTail(messages: readonly ChannelHistoryMessage[], head: number, tail: number): Sliced[] {
|
package/src/channels/schema.ts
CHANGED
|
@@ -87,6 +87,26 @@ const historySchema = z
|
|
|
87
87
|
},
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
// When the agent's first send of a turn lands ≥ this many ms after the
|
|
91
|
+
// inbound was received, OR there were intervening observed messages
|
|
92
|
+
// between the inbound and the reply, the router prepends a `> @author:
|
|
93
|
+
// ...` blockquote line referencing the inbound so the user can see which
|
|
94
|
+
// message the reply is anchored to even after the channel has scrolled.
|
|
95
|
+
// 10s is the empirical "felt instantaneous" ceiling — anything faster
|
|
96
|
+
// reads as real-time and needs no anchor.
|
|
97
|
+
export const DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS = 10_000
|
|
98
|
+
|
|
99
|
+
// Long enough to disambiguate; short enough that a multi-paragraph user
|
|
100
|
+
// message doesn't visually dominate the reply.
|
|
101
|
+
export const QUOTED_REPLY_EXCERPT_MAX_CHARS = 100
|
|
102
|
+
|
|
103
|
+
const quotedReplySchema = z
|
|
104
|
+
.object({
|
|
105
|
+
enabled: z.boolean().default(true),
|
|
106
|
+
queueDelayMs: z.number().int().min(0).default(DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS),
|
|
107
|
+
})
|
|
108
|
+
.default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
|
|
109
|
+
|
|
90
110
|
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
91
111
|
// legacy `allow` field (`migrateLegacyConfigShape` lifts it into
|
|
92
112
|
// `roles.member.match[]` on load, but a between-reload window can
|
|
@@ -97,6 +117,7 @@ const adapterSchema = z.object({
|
|
|
97
117
|
engagement: engagementSchema,
|
|
98
118
|
history: historySchema,
|
|
99
119
|
enabled: z.boolean().default(true),
|
|
120
|
+
quotedReply: quotedReplySchema.optional(),
|
|
100
121
|
})
|
|
101
122
|
|
|
102
123
|
export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
package/src/cli/cron.ts
CHANGED
|
@@ -38,7 +38,7 @@ const listSub = defineCommand({
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
let url: string | undefined = args.url
|
|
41
|
-
if (url === undefined) {
|
|
41
|
+
if (url === undefined && process.env.TYPECLAW_CONTAINER_NAME === undefined) {
|
|
42
42
|
const precheck = await requireContainerRunning({ cwd })
|
|
43
43
|
if (!precheck.ok) {
|
|
44
44
|
console.error(errorLine(precheck.reason))
|