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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.9.2",
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.17.0",
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",
@@ -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 normally resolves to `owner` via the built-in `owner.match = [tui]`
894
- // entry, and we skip the role block in that case to save tokens on every
895
- // interactive session. But user-declared roles can match TUI first (the
896
- // resolver is first-match-wins in declaration order), so a non-owner TUI
897
- // role is possible and the agent needs to see it. The "TUI is always owner"
898
- // shorthand in docs is the common case, not an invariant.
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
- 'only owner has it by default (medium tier; trusted does NOT carry this operators can grant `security.bypass.secretExfilBash` explicitly in roles.trusted.permissions[] if they want the pre-PR ergonomics back)',
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
- 'NOBODY has it by default high tier requires per-call ack from every role, including owner. Operators can grant `security.bypass.gitExfil` explicitly in roles.<role>.permissions[] to re-open the auto-bypass for one role.',
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
- 'NOBODY has it by default high tier requires per-call ack from every role. Even an operator-granted `security.bypass.gitExfil` does NOT bypass this second-step taint check (the recorder still fires for the first step, so the push is still gated).',
57
- [SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default (medium tier)',
58
- [SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default (medium tier)',
59
- [SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default (medium tier)',
60
- [SECURITY_PERMISSIONS.bypassSystemPromptLeak]:
61
- 'NOBODY has it by default high tier requires per-call ack from every role, including owner.',
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
- 'NOBODY has it by default high tier requires per-call ack from every role, including owner. The audience-leak rule: even owner posting to a public channel must not silently include credentials.',
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
- 'NOBODY has it by default high tier requires per-call ack from every role, including owner. The audience-leak rule generalizes to privilege escalation: even owner running from TUI must not silently rewrite the access-control table on behalf of a channel message.',
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
- 'NOBODY has it by default high tier requires per-call ack from every role, including owner. Same shape as rolePromotion but deferred: a new cron job (or a changed scheduledByRole) is a privilege grant that fires at schedule-time, and the operator must not silently author one on behalf of a channel message.',
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
- // High-tier per-guard strings AND the `security.bypass.high` tier
87
- // string itself are excluded from the owner-wildcard expansion. Owner
88
- // still has the wildcard sentinel (so future low/medium plugin-
89
- // contributed bypasses keep auto-flowing to owner), but audience-leak
90
- // guards require either per-call ack or an explicit operator grant.
91
- ownerWildcardExclusions: [...HIGH_TIER_PER_GUARD_PERMISSIONS, SECURITY_PERMISSIONS.bypassHigh],
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`. The
41
- // owner-wildcard expander excludes these so the wildcard sentinel does
42
- // not auto-grant high-tier bypass to owner. Operators who explicitly
43
- // want to re-open a high-tier bypass for owner (or any role) can still
44
- // add the per-guard string to that role's `permissions[]` by hand.
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 `high` (audience-leak axis, adapted same reasoning as
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: a `member`-role agent that can `write`
21
- // `cron.json` authors a brand-new job with `"scheduledByRole": "owner"`
22
- // and a prompt that does whatever the agent's tool surface allows when
23
- // running as owner. The cron consumer fires it on schedule; the firing
24
- // session resolves to `owner` because that role name exists in the role
25
- // table. The agent has laundered itself into owner via the schedule.
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 = 'high'
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 `high` (audience-leak axis): `git push` sends every tracked
7
- // file to a remote git host. The host (GitHub/GitLab/attacker-controlled
8
- // box) is a third-party audience outside the operator's control loop.
9
- // Even a private remote owned by an attacker is now outside the
10
- // perimeter. No role auto-bypasses high owner pushing from TUI must ack
11
- // each push. The historical per-guard string `security.bypass.gitExfil`
12
- // remains valid as an explicit grant for operators who knowingly want to
13
- // re-open the auto-bypass (see SKILL.md must-not-do guidance).
14
- export const GUARD_GIT_EXFIL_SEVERITY: SecuritySeverity = 'high'
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): same path as gitExfil, second
17
- // step. A push after a mid-session `git remote set-url` to an
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. The recorder-vs-checker split
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 command (ack
22
- // or the per-guard `bypassGitExfil` grant), so even if an operator
23
- // explicitly grants `bypassGitExfil` to a role, the second-step taint
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 `high` (audience-leak axis, adapted).
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
- // Role promotion is privilege escalation: the agent rewrites
14
- // `typeclaw.json#roles` so a previously-unprivileged actor now resolves
15
- // to a privileged role. The breach pattern: a `member`-role speaker in a
16
- // chat asks "give me permission" / "promote me to admin"; the agent
17
- // edits typeclaw.json with what looks like a routine config change; the
18
- // schema is valid, the managedConfig guard passes, nonWorkspaceWrite
19
- // allowlists typeclaw.json and on next reload the speaker resolves to
20
- // `owner` with full bypasses.
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
- // This is the same audience-leak shape as gitExfil and outboundSecret:
23
- // the "audience" here is the future-self of the access-control table,
24
- // which is outside the operator's per-call control loop. Even an `owner`
25
- // operating from TUI must not silently rewrite the role table based on
26
- // a channel message — the canonical owner-in-public-channel attack
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 = 'high'
68
+ export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = 'medium'
62
69
 
63
70
  export type RolePromotionFinding = {
64
71
  role: string
@@ -29,7 +29,11 @@ import {
29
29
  saveChannelSessions,
30
30
  type ChannelSessionRecord,
31
31
  } from './persistence'
32
- import type { ChannelAdapterConfig } from './schema'
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: only DMs whose text contains
1344
- // a `claim-` prefix can be claim attempts, and only when a handler
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
- if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
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[] {
@@ -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))