typeclaw 0.1.4 → 0.1.6
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 +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
|
@@ -6,7 +6,7 @@ import type { ChannelParticipant } from '@/agent/session-origin'
|
|
|
6
6
|
import type { AdapterId } from './schema'
|
|
7
7
|
import type { ChannelKey } from './types'
|
|
8
8
|
|
|
9
|
-
const FILE_VERSION =
|
|
9
|
+
const FILE_VERSION = 4
|
|
10
10
|
|
|
11
11
|
// `sessionFile` is the basename (not the full path) of the JSONL transcript
|
|
12
12
|
// for this (adapter, workspace, chat, thread) tuple. pi-coding-agent writes
|
|
@@ -25,11 +25,17 @@ export type ChannelSessionRecord = {
|
|
|
25
25
|
workspace: string
|
|
26
26
|
chat: string
|
|
27
27
|
thread: string | null
|
|
28
|
-
sessionId
|
|
28
|
+
sessionId?: string
|
|
29
29
|
sessionFile?: string
|
|
30
|
+
lastInboundAt?: number
|
|
30
31
|
participants: ChannelParticipant[]
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
type FileV4 = {
|
|
35
|
+
version: 4
|
|
36
|
+
sessions: ChannelSessionRecord[]
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
type FileV3 = {
|
|
34
40
|
version: 3
|
|
35
41
|
sessions: ChannelSessionRecord[]
|
|
@@ -41,11 +47,13 @@ type FileV2 = {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
export type ChannelSessionsLogger = {
|
|
50
|
+
info: (msg: string) => void
|
|
44
51
|
warn: (msg: string) => void
|
|
45
52
|
error: (msg: string) => void
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
const consoleLogger: ChannelSessionsLogger = {
|
|
56
|
+
info: (m) => console.log(m),
|
|
49
57
|
warn: (m) => console.warn(m),
|
|
50
58
|
error: (m) => console.error(m),
|
|
51
59
|
}
|
|
@@ -82,17 +90,25 @@ export async function loadChannelSessions(
|
|
|
82
90
|
}
|
|
83
91
|
const version = (parsed as { version?: unknown }).version
|
|
84
92
|
if (version === FILE_VERSION) {
|
|
85
|
-
const file = parsed as
|
|
93
|
+
const file = parsed as FileV4
|
|
86
94
|
if (!Array.isArray(file.sessions)) return []
|
|
87
95
|
return file.sessions.filter(isValidRecord)
|
|
88
96
|
}
|
|
97
|
+
if (version === 3) {
|
|
98
|
+
const file = parsed as FileV3
|
|
99
|
+
if (!Array.isArray(file.sessions)) return []
|
|
100
|
+
return migrateV3ToV4(file.sessions.filter(isValidRecord), logger)
|
|
101
|
+
}
|
|
89
102
|
if (version === 2) {
|
|
90
103
|
const file = parsed as FileV2
|
|
91
104
|
if (!Array.isArray(file.sessions)) return []
|
|
92
105
|
const v2Records = file.sessions.filter(isValidV2Record)
|
|
93
|
-
|
|
106
|
+
const v3Records = await migrateV2Records(agentDir, v2Records, logger)
|
|
107
|
+
return migrateV3ToV4(v3Records, logger)
|
|
94
108
|
}
|
|
95
|
-
logger.warn(
|
|
109
|
+
logger.warn(
|
|
110
|
+
`[channels] ${path} version ${String(version)} not supported (expected 2, 3, or ${FILE_VERSION}); ignored`,
|
|
111
|
+
)
|
|
96
112
|
return []
|
|
97
113
|
}
|
|
98
114
|
|
|
@@ -102,7 +118,7 @@ export async function saveChannelSessions(
|
|
|
102
118
|
logger: ChannelSessionsLogger = consoleLogger,
|
|
103
119
|
): Promise<void> {
|
|
104
120
|
const path = channelsSessionsPath(agentDir)
|
|
105
|
-
const payload:
|
|
121
|
+
const payload: FileV4 = { version: FILE_VERSION, sessions: dedupe(sessions) }
|
|
106
122
|
try {
|
|
107
123
|
await mkdir(dirname(path), { recursive: true })
|
|
108
124
|
const tmp = `${path}.tmp`
|
|
@@ -123,7 +139,7 @@ export async function saveChannelSessions(
|
|
|
123
139
|
// we'll be migrated forward.)
|
|
124
140
|
async function migrateV2Records(
|
|
125
141
|
agentDir: string,
|
|
126
|
-
v2Records: readonly Omit<ChannelSessionRecord, 'sessionFile'>[],
|
|
142
|
+
v2Records: readonly (Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string })[],
|
|
127
143
|
logger: ChannelSessionsLogger,
|
|
128
144
|
): Promise<ChannelSessionRecord[]> {
|
|
129
145
|
if (v2Records.length === 0) return []
|
|
@@ -160,6 +176,13 @@ async function migrateV2Records(
|
|
|
160
176
|
})
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
function migrateV3ToV4(v3Records: ChannelSessionRecord[], logger: ChannelSessionsLogger): ChannelSessionRecord[] {
|
|
180
|
+
logger.info(
|
|
181
|
+
`[channels] v3→v4: ${v3Records.length} record(s) migrated; first post-upgrade inbound will force fresh session`,
|
|
182
|
+
)
|
|
183
|
+
return v3Records.map((r) => ({ ...r, lastInboundAt: 0 }))
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
|
|
164
187
|
const seen = new Map<string, ChannelSessionRecord>()
|
|
165
188
|
for (const s of sessions) {
|
|
@@ -185,7 +208,9 @@ function isObject(v: unknown): v is Record<string, unknown> {
|
|
|
185
208
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
186
209
|
}
|
|
187
210
|
|
|
188
|
-
function isValidV2Record(
|
|
211
|
+
function isValidV2Record(
|
|
212
|
+
v: unknown,
|
|
213
|
+
): v is Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string } {
|
|
189
214
|
if (!isObject(v)) return false
|
|
190
215
|
const r = v as Record<string, unknown>
|
|
191
216
|
return (
|
|
@@ -199,9 +224,18 @@ function isValidV2Record(v: unknown): v is Omit<ChannelSessionRecord, 'sessionFi
|
|
|
199
224
|
}
|
|
200
225
|
|
|
201
226
|
function isValidRecord(v: unknown): v is ChannelSessionRecord {
|
|
202
|
-
if (!
|
|
227
|
+
if (!isObject(v)) return false
|
|
203
228
|
const r = v as Record<string, unknown>
|
|
204
|
-
return
|
|
229
|
+
return (
|
|
230
|
+
typeof r.adapter === 'string' &&
|
|
231
|
+
typeof r.workspace === 'string' &&
|
|
232
|
+
typeof r.chat === 'string' &&
|
|
233
|
+
(r.thread === null || typeof r.thread === 'string') &&
|
|
234
|
+
(r.sessionId === undefined || typeof r.sessionId === 'string') &&
|
|
235
|
+
(r.sessionFile === undefined || typeof r.sessionFile === 'string') &&
|
|
236
|
+
(r.lastInboundAt === undefined || typeof r.lastInboundAt === 'number') &&
|
|
237
|
+
Array.isArray(r.participants)
|
|
238
|
+
)
|
|
205
239
|
}
|
|
206
240
|
|
|
207
241
|
function describe(err: unknown): string {
|
package/src/channels/router.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
|
6
6
|
import { createSession, type AgentSession } from '@/agent'
|
|
7
7
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
8
8
|
import { createCommandRegistry } from '@/commands'
|
|
9
|
+
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
9
10
|
import type { HookBus } from '@/plugin'
|
|
11
|
+
import { extractClaimCode } from '@/role-claim'
|
|
10
12
|
|
|
11
13
|
import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
|
|
12
14
|
import {
|
|
@@ -75,6 +77,18 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
|
75
77
|
export const SESSION_IDLE_MS = 30 * 60 * 1000
|
|
76
78
|
export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
82
|
+
* Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
|
|
83
|
+
* guaranteed to be a cache hit on the provider side.
|
|
84
|
+
*
|
|
85
|
+
* Unlike SESSION_IDLE_MS (which evicts the in-memory entry without rollover), this constant
|
|
86
|
+
* triggers a full tearDownLive + recreate on the next engaged inbound. The old session's
|
|
87
|
+
* transcript is preserved on disk; only the in-memory live entry and sessions.json pointer
|
|
88
|
+
* are replaced.
|
|
89
|
+
*/
|
|
90
|
+
export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
|
|
91
|
+
|
|
78
92
|
// Watchdog ceiling for ensureLive's full async chain (resolve names →
|
|
79
93
|
// fetch membership → open session manager → persist mapping → prefetch
|
|
80
94
|
// history). A legitimate cold-start completes in well under a second;
|
|
@@ -154,6 +168,12 @@ export type CreateSessionForChannel = (params: {
|
|
|
154
168
|
existingSessionFile?: string
|
|
155
169
|
participants: readonly ChannelParticipant[]
|
|
156
170
|
origin: SessionOrigin
|
|
171
|
+
// Mutable holder the router updates per turn (with the current turn's
|
|
172
|
+
// lastInboundAuthorId, participants, etc.) so tool.before events stamp
|
|
173
|
+
// the live actor identity rather than the cold-start snapshot. The
|
|
174
|
+
// factory is expected to pass this through to createSession as
|
|
175
|
+
// `options.originRef`.
|
|
176
|
+
originRef: { current: SessionOrigin | undefined }
|
|
157
177
|
}) => Promise<{
|
|
158
178
|
session: AgentSession
|
|
159
179
|
sessionId: string
|
|
@@ -201,6 +221,7 @@ type LiveSession = {
|
|
|
201
221
|
getTranscriptPath: (() => string | undefined) | undefined
|
|
202
222
|
participants: ChannelParticipant[]
|
|
203
223
|
resolvedNames: ResolvedChannelNames
|
|
224
|
+
originRef: { current: SessionOrigin | undefined }
|
|
204
225
|
promptQueue: QueuedInbound[]
|
|
205
226
|
contextBuffer: ObservedInbound[]
|
|
206
227
|
draining: boolean
|
|
@@ -308,6 +329,46 @@ export type CreateChannelRouterOptions = {
|
|
|
308
329
|
// Test seam: bound the session.idle hook chain so the timeout path is
|
|
309
330
|
// exercisable in tens of milliseconds instead of the 30s default.
|
|
310
331
|
sessionIdleTimeoutMs?: number
|
|
332
|
+
// Wake-up gate: every inbound is gated by `permissions.has(partialOrigin,
|
|
333
|
+
// 'channel.respond')` BEFORE ensureLive. Required by the production
|
|
334
|
+
// wiring (manager.ts forwards `pluginsLoaded.permissions`); defaulted
|
|
335
|
+
// to a grant-all service inside the factory so existing direct test
|
|
336
|
+
// instantiations don't need to inject one. The default is intentionally
|
|
337
|
+
// permissive — the manager-to-router seam is the place where production
|
|
338
|
+
// injection is enforced; direct-router tests opt into gate semantics by
|
|
339
|
+
// passing their own service.
|
|
340
|
+
permissions?: PermissionService
|
|
341
|
+
// Optional role-claim handler. When set, the router intercepts DM
|
|
342
|
+
// inbounds whose text contains a claim code BEFORE the channel.respond
|
|
343
|
+
// gate, hands the inbound to the handler, and short-circuits the normal
|
|
344
|
+
// route path (no session creation, no permission check, no engagement
|
|
345
|
+
// pipeline). The handler returns the reply text the router should send
|
|
346
|
+
// back over the same chat, or null to fall through to normal routing
|
|
347
|
+
// when no pending claim window matches.
|
|
348
|
+
claimHandler?: ClaimHandler
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type ClaimHandlerInput = {
|
|
352
|
+
adapter: ChannelKey['adapter']
|
|
353
|
+
workspace: string
|
|
354
|
+
chat: string
|
|
355
|
+
isDm: boolean
|
|
356
|
+
authorId: string
|
|
357
|
+
text: string
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type ClaimHandlerOutcome =
|
|
361
|
+
| { kind: 'consumed'; reply: string }
|
|
362
|
+
| { kind: 'fail'; reply: string }
|
|
363
|
+
| { kind: 'fallthrough' }
|
|
364
|
+
|
|
365
|
+
export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
|
|
366
|
+
|
|
367
|
+
const GRANT_ALL_PERMISSIONS: PermissionService = {
|
|
368
|
+
has: () => true,
|
|
369
|
+
resolveRole: () => 'owner',
|
|
370
|
+
describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
|
|
371
|
+
replaceRoles: () => {},
|
|
311
372
|
}
|
|
312
373
|
|
|
313
374
|
export function createChannelRouter(options: CreateChannelRouterOptions): ChannelRouter {
|
|
@@ -317,6 +378,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
317
378
|
const resolveChannelNamesTimeoutMs = options.resolveChannelNamesTimeoutMs ?? RESOLVE_CHANNEL_NAMES_TIMEOUT_MS
|
|
318
379
|
const fetchHistoryTimeoutMs = options.fetchHistoryTimeoutMs ?? FETCH_HISTORY_TIMEOUT_MS
|
|
319
380
|
const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
|
|
381
|
+
const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
|
|
382
|
+
const claimHandler = options.claimHandler
|
|
320
383
|
const liveSessions = new Map<string, LiveSession>()
|
|
321
384
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
322
385
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
@@ -355,6 +418,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
355
418
|
|
|
356
419
|
let mappings: ChannelSessionRecord[] | null = null
|
|
357
420
|
let loadOnce: Promise<void> | null = null
|
|
421
|
+
let persistChain: Promise<void> = Promise.resolve()
|
|
358
422
|
|
|
359
423
|
const ensureLoaded = async (): Promise<void> => {
|
|
360
424
|
if (mappings !== null) return
|
|
@@ -368,12 +432,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
368
432
|
|
|
369
433
|
const persist = async (): Promise<void> => {
|
|
370
434
|
if (mappings === null) return
|
|
371
|
-
|
|
435
|
+
persistChain = persistChain.then(async () => {
|
|
436
|
+
if (mappings === null) return
|
|
437
|
+
await saveChannelSessions(options.agentDir, mappings, logger)
|
|
438
|
+
})
|
|
439
|
+
await persistChain
|
|
372
440
|
}
|
|
373
441
|
|
|
374
442
|
const createForChannel: CreateSessionForChannel =
|
|
375
443
|
options.createSessionForChannel ??
|
|
376
|
-
(async ({ key, existingSessionId, existingSessionFile, origin }) => {
|
|
444
|
+
(async ({ key, existingSessionId, existingSessionFile, origin, originRef }) => {
|
|
377
445
|
const sessionDir = options.sessionDir ?? `${options.agentDir}/sessions`
|
|
378
446
|
const sessionManager =
|
|
379
447
|
existingSessionId !== undefined
|
|
@@ -382,6 +450,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
382
450
|
const session = await createSession({
|
|
383
451
|
sessionManager,
|
|
384
452
|
origin,
|
|
453
|
+
originRef,
|
|
385
454
|
})
|
|
386
455
|
const sessionId = sessionManager.getSessionId()
|
|
387
456
|
void key
|
|
@@ -476,10 +545,44 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
476
545
|
return membership
|
|
477
546
|
}
|
|
478
547
|
|
|
479
|
-
const ensureLive = async (
|
|
548
|
+
const ensureLive = async (
|
|
549
|
+
key: ChannelKey,
|
|
550
|
+
triggeringMessageId?: string,
|
|
551
|
+
triggeringAuthorId?: string,
|
|
552
|
+
): Promise<LiveSession> => {
|
|
480
553
|
const keyId = channelKeyId(key)
|
|
481
554
|
const existing = liveSessions.get(keyId)
|
|
482
|
-
if (existing && !existing.destroyed)
|
|
555
|
+
if (existing && !existing.destroyed) {
|
|
556
|
+
const idleMs = now() - existing.lastInboundAt
|
|
557
|
+
if (idleMs > SESSION_FRESHNESS_TTL_MS) {
|
|
558
|
+
logger.info(`[channels] ${keyId}: stale-rollover (live: ${idleMs}ms idle)`)
|
|
559
|
+
await tearDownLive(existing)
|
|
560
|
+
liveSessions.delete(keyId)
|
|
561
|
+
if (mappings) {
|
|
562
|
+
const idx = mappings.findIndex(
|
|
563
|
+
(s) =>
|
|
564
|
+
s.adapter === key.adapter &&
|
|
565
|
+
s.workspace === key.workspace &&
|
|
566
|
+
s.chat === key.chat &&
|
|
567
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
568
|
+
)
|
|
569
|
+
if (idx >= 0) {
|
|
570
|
+
const prev = mappings[idx]!
|
|
571
|
+
mappings[idx] = {
|
|
572
|
+
adapter: prev.adapter,
|
|
573
|
+
workspace: prev.workspace,
|
|
574
|
+
chat: prev.chat,
|
|
575
|
+
thread: prev.thread,
|
|
576
|
+
participants: prev.participants,
|
|
577
|
+
lastInboundAt: 0,
|
|
578
|
+
}
|
|
579
|
+
await persist()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
return existing
|
|
584
|
+
}
|
|
585
|
+
}
|
|
483
586
|
|
|
484
587
|
const inFlight = creating.get(keyId)
|
|
485
588
|
if (inFlight) return inFlight
|
|
@@ -487,14 +590,51 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
487
590
|
const promise = (async () => {
|
|
488
591
|
await ensureLoaded()
|
|
489
592
|
const record = mappings ? findRecord(mappings, key) : undefined
|
|
490
|
-
|
|
593
|
+
let resolvedRecord = record
|
|
594
|
+
if (
|
|
595
|
+
record?.sessionId !== undefined &&
|
|
596
|
+
existing === undefined &&
|
|
597
|
+
now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
|
|
598
|
+
) {
|
|
599
|
+
const idleMs = now() - (record.lastInboundAt ?? 0)
|
|
600
|
+
logger.info(`[channels] ${keyId}: stale-rollover (persisted: ${idleMs}ms idle)`)
|
|
601
|
+
resolvedRecord = {
|
|
602
|
+
adapter: record.adapter,
|
|
603
|
+
workspace: record.workspace,
|
|
604
|
+
chat: record.chat,
|
|
605
|
+
thread: record.thread,
|
|
606
|
+
participants: record.participants,
|
|
607
|
+
lastInboundAt: 0,
|
|
608
|
+
}
|
|
609
|
+
if (mappings) {
|
|
610
|
+
const idx = mappings.findIndex(
|
|
611
|
+
(s) =>
|
|
612
|
+
s.adapter === key.adapter &&
|
|
613
|
+
s.workspace === key.workspace &&
|
|
614
|
+
s.chat === key.chat &&
|
|
615
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
616
|
+
)
|
|
617
|
+
if (idx >= 0) {
|
|
618
|
+
mappings[idx] = resolvedRecord
|
|
619
|
+
await persist()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
|
|
491
624
|
logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
|
|
492
|
-
const participants = (
|
|
625
|
+
const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
|
|
493
626
|
const membershipFetch = warmMembership(key)
|
|
494
627
|
const resolvedNames = await resolveChannelNames(key)
|
|
495
628
|
logger.info(`[channels] ${keyId}: ensureLive resolved-names`)
|
|
496
629
|
const membership = await membershipForPrompt(key, membershipFetch)
|
|
497
630
|
logger.info(`[channels] ${keyId}: ensureLive resolved-membership`)
|
|
631
|
+
// The session-creation origin is what the resource loader sees when it
|
|
632
|
+
// renders the role/permissions block into the system prompt. It must
|
|
633
|
+
// include the triggering author so author-scoped roles
|
|
634
|
+
// (`slack:T/C author:U_ME`) resolve to the same role here that the
|
|
635
|
+
// channel.respond gate just admitted on. Per-turn updates after this
|
|
636
|
+
// point are handled by `originRef.current = buildLiveOrigin(live)`
|
|
637
|
+
// before each prompt() call.
|
|
498
638
|
const origin: SessionOrigin = {
|
|
499
639
|
kind: 'channel',
|
|
500
640
|
adapter: key.adapter,
|
|
@@ -503,18 +643,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
503
643
|
chat: key.chat,
|
|
504
644
|
...(resolvedNames.chatName !== undefined ? { chatName: resolvedNames.chatName } : {}),
|
|
505
645
|
thread: key.thread,
|
|
646
|
+
...(triggeringAuthorId !== undefined ? { lastInboundAuthorId: triggeringAuthorId } : {}),
|
|
506
647
|
participants,
|
|
507
648
|
...(membership !== null ? { membership } : {}),
|
|
508
649
|
}
|
|
509
650
|
|
|
510
|
-
const isColdStart =
|
|
651
|
+
const isColdStart = resolvedRecord?.sessionId === undefined
|
|
652
|
+
|
|
653
|
+
// The router writes into this holder before every prompt() so the
|
|
654
|
+
// tool wrappers' getOrigin() sees the current-turn origin.
|
|
655
|
+
const originRef: { current: SessionOrigin | undefined } = { current: origin }
|
|
511
656
|
|
|
512
657
|
const created = await createForChannel({
|
|
513
658
|
key,
|
|
514
|
-
...(
|
|
515
|
-
...(
|
|
659
|
+
...(resolvedRecord?.sessionId ? { existingSessionId: resolvedRecord.sessionId } : {}),
|
|
660
|
+
...(resolvedRecord?.sessionFile ? { existingSessionFile: resolvedRecord.sessionFile } : {}),
|
|
516
661
|
participants,
|
|
517
662
|
origin,
|
|
663
|
+
originRef,
|
|
518
664
|
})
|
|
519
665
|
logger.info(`[channels] ${keyId}: ensureLive session-created sessionId=${created.sessionId}`)
|
|
520
666
|
|
|
@@ -526,6 +672,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
526
672
|
thread: key.thread,
|
|
527
673
|
sessionId: created.sessionId,
|
|
528
674
|
...(transcriptPath ? { sessionFile: basename(transcriptPath) } : {}),
|
|
675
|
+
lastInboundAt: now(),
|
|
529
676
|
participants,
|
|
530
677
|
}
|
|
531
678
|
if (mappings) {
|
|
@@ -553,6 +700,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
553
700
|
getTranscriptPath: created.getTranscriptPath,
|
|
554
701
|
participants,
|
|
555
702
|
resolvedNames,
|
|
703
|
+
originRef,
|
|
556
704
|
promptQueue: [],
|
|
557
705
|
contextBuffer: [],
|
|
558
706
|
draining: false,
|
|
@@ -697,8 +845,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
697
845
|
await persist()
|
|
698
846
|
}
|
|
699
847
|
|
|
700
|
-
const regenerateOrigin = (live: LiveSession): SessionOrigin => buildLiveOrigin(live)
|
|
701
|
-
|
|
702
848
|
const fireTyping = async (live: LiveSession, phase: 'tick' | 'stop'): Promise<void> => {
|
|
703
849
|
const callbacks = typingCallbacks.get(live.key.adapter)
|
|
704
850
|
if (!callbacks || callbacks.size === 0) return
|
|
@@ -860,13 +1006,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
860
1006
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
861
1007
|
if (batch.length > 0) live.consecutiveSends.clear()
|
|
862
1008
|
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
1009
|
+
// Update the live origin holder so this turn's tool.before events
|
|
1010
|
+
// carry the current actor's id. The DefaultResourceLoader still
|
|
1011
|
+
// renders the session-creation origin into the system prompt (v0.2
|
|
1012
|
+
// work to regenerate that per-turn); but permission gating off
|
|
1013
|
+
// `lastInboundAuthorId` happens in the tool layer and now sees the
|
|
1014
|
+
// live value.
|
|
1015
|
+
live.originRef.current = buildLiveOrigin(live)
|
|
870
1016
|
|
|
871
1017
|
// Bracketing logs around the LLM call so a hung prompt() is
|
|
872
1018
|
// diagnosable from logs alone (we see prompting without prompted).
|
|
@@ -906,6 +1052,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
906
1052
|
const elapsedSinceFirst = t - live.firstUnprocessedAt
|
|
907
1053
|
const wait = Math.max(0, Math.min(baseWait, MAX_DEBOUNCE_MS - elapsedSinceFirst))
|
|
908
1054
|
live.lastInboundAt = t
|
|
1055
|
+
if (mappings) {
|
|
1056
|
+
const idx = mappings.findIndex(
|
|
1057
|
+
(s) =>
|
|
1058
|
+
s.adapter === live.key.adapter &&
|
|
1059
|
+
s.workspace === live.key.workspace &&
|
|
1060
|
+
s.chat === live.key.chat &&
|
|
1061
|
+
(s.thread ?? null) === (live.key.thread ?? null),
|
|
1062
|
+
)
|
|
1063
|
+
if (idx >= 0) {
|
|
1064
|
+
mappings[idx] = { ...mappings[idx]!, lastInboundAt: t }
|
|
1065
|
+
void persist()
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
909
1068
|
live.debounceTimer = setTimeout(() => {
|
|
910
1069
|
live.debounceTimer = null
|
|
911
1070
|
live.firstUnprocessedAt = 0
|
|
@@ -924,8 +1083,46 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
924
1083
|
thread: event.thread,
|
|
925
1084
|
}
|
|
926
1085
|
|
|
1086
|
+
// Role-claim intercept runs BEFORE the channel.respond gate so the
|
|
1087
|
+
// operator can bootstrap permissions on a fresh agent that has no
|
|
1088
|
+
// role match rules yet. Cheap pre-check: only DMs whose text contains
|
|
1089
|
+
// a `claim-` prefix can be claim attempts, and only when a handler
|
|
1090
|
+
// is registered. Everything else falls straight through to the gate.
|
|
1091
|
+
if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
|
|
1092
|
+
const outcome = await claimHandler({
|
|
1093
|
+
adapter: event.adapter,
|
|
1094
|
+
workspace: event.workspace,
|
|
1095
|
+
chat: event.chat,
|
|
1096
|
+
isDm: event.isDm,
|
|
1097
|
+
authorId: event.authorId,
|
|
1098
|
+
text: event.text,
|
|
1099
|
+
})
|
|
1100
|
+
if (outcome.kind !== 'fallthrough') {
|
|
1101
|
+
logger.info(
|
|
1102
|
+
`[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
|
|
1103
|
+
)
|
|
1104
|
+
await send({
|
|
1105
|
+
adapter: event.adapter,
|
|
1106
|
+
workspace: event.workspace,
|
|
1107
|
+
chat: event.chat,
|
|
1108
|
+
thread: event.thread,
|
|
1109
|
+
text: outcome.reply,
|
|
1110
|
+
})
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (isChannelRespondDenied(event)) {
|
|
1116
|
+
logger.info(
|
|
1117
|
+
`[channels] ${channelKeyId(key)}: denied by permissions (channel.respond) author=${event.authorId} id=${event.externalMessageId}`,
|
|
1118
|
+
)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
|
|
927
1122
|
const parsedCommand = commands.parse(event.text)
|
|
928
1123
|
if (parsedCommand !== null) {
|
|
1124
|
+
// Commands are control traffic, not engaged inbounds; if the session is stale,
|
|
1125
|
+
// the next engaged inbound will perform the rollover before prompting.
|
|
929
1126
|
const keyId = channelKeyId(key)
|
|
930
1127
|
if (!commands.has(parsedCommand.name)) {
|
|
931
1128
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
@@ -940,7 +1137,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
940
1137
|
if (commandResult.kind !== 'not-command') return
|
|
941
1138
|
}
|
|
942
1139
|
|
|
943
|
-
const live = await ensureLive(key, event.externalMessageId)
|
|
1140
|
+
const live = await ensureLive(key, event.externalMessageId, event.authorId)
|
|
944
1141
|
|
|
945
1142
|
const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
|
|
946
1143
|
live.participants = updateParticipants(
|
|
@@ -1010,6 +1207,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1010
1207
|
scheduleDebouncedDrain(live)
|
|
1011
1208
|
}
|
|
1012
1209
|
|
|
1210
|
+
const isChannelRespondDenied = (event: InboundMessage): boolean => {
|
|
1211
|
+
const partial: SessionOrigin = {
|
|
1212
|
+
kind: 'channel',
|
|
1213
|
+
adapter: event.adapter,
|
|
1214
|
+
workspace: event.workspace,
|
|
1215
|
+
chat: event.chat,
|
|
1216
|
+
thread: event.thread,
|
|
1217
|
+
lastInboundAuthorId: event.authorId,
|
|
1218
|
+
}
|
|
1219
|
+
return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1013
1222
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1014
1223
|
if (!event.authorIsBot) {
|
|
1015
1224
|
live.recentEngagedPeerBotTurns.length = 0
|