typeclaw 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/package.json +5 -3
- package/scripts/dump-system-prompt.ts +12 -1
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +116 -14
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +97 -13
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +49 -15
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +328 -18
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +353 -2
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +4 -1
- package/src/shared/local-time.ts +17 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +38 -33
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +26 -15
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
package/src/channels/router.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
|
6
6
|
import { createSession, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
|
+
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
9
10
|
import { createCommandRegistry } from '@/commands'
|
|
10
11
|
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
11
12
|
import type { HookBus } from '@/plugin'
|
|
@@ -254,6 +255,14 @@ type LiveSession = {
|
|
|
254
255
|
currentTurnAuthorId: string | null
|
|
255
256
|
currentTurnAuthorIds: Set<string>
|
|
256
257
|
lastTurnAuthorIds: Set<string>
|
|
258
|
+
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
259
|
+
// prior batch), preserved across the drain finally-block which resets
|
|
260
|
+
// currentTurnAuthorId to null. Read by the reminder-only branch in
|
|
261
|
+
// drain() so a system-reminder wakeup carries the same author the prior
|
|
262
|
+
// turn's tool.before saw — matching "last speaker" semantics (not "first
|
|
263
|
+
// inserted into Set"), so a multi-author prior turn like alice→bob
|
|
264
|
+
// restores `bob`, the same identity normal turns would have used.
|
|
265
|
+
lastTurnAuthorId: string | null
|
|
257
266
|
consecutiveAborts: number
|
|
258
267
|
// Per-(chat:thread) count of bot messages sent without intervening user
|
|
259
268
|
// input being rendered into the model's context. Reset at the top of each
|
|
@@ -261,6 +270,15 @@ type LiveSession = {
|
|
|
261
270
|
// about to be shown to the model). channel_send reads this BEFORE calling
|
|
262
271
|
// router.send so the hint reflects the position of the about-to-happen send
|
|
263
272
|
// (n-th in a row), nudging the model to yield without forcing it to.
|
|
273
|
+
// Queue of `<system-reminder>...</system-reminder>` strings to prepend
|
|
274
|
+
// into the next turn's user-message body. Populated by
|
|
275
|
+
// `injectSubagentCompletionReminder` (and any future system-injected
|
|
276
|
+
// wakeups) so a backgrounded subagent's completion can wake a channel
|
|
277
|
+
// session that has no pending user inbounds. Drained at the top of
|
|
278
|
+
// every `drain()` iteration alongside the regular promptQueue batch;
|
|
279
|
+
// the drain loop's run condition checks BOTH queues so a system
|
|
280
|
+
// reminder alone is enough to trigger a turn.
|
|
281
|
+
pendingSystemReminders: string[]
|
|
264
282
|
consecutiveSends: Map<string, number>
|
|
265
283
|
// Per-(chat:thread) text of the last reserved bot send. Set
|
|
266
284
|
// SYNCHRONOUSLY inside router.send before the outbound callback awaits,
|
|
@@ -297,9 +315,30 @@ type LiveSession = {
|
|
|
297
315
|
unsubProviderErrors: (() => void) | null
|
|
298
316
|
}
|
|
299
317
|
|
|
318
|
+
// `event` is null for command invocations that originated outside the inbound
|
|
319
|
+
// pipeline (e.g. Discord native slash commands fired from listener.on
|
|
320
|
+
// ('interaction_create')). Handlers that need a real inbound — for some
|
|
321
|
+
// future hypothetical command like `/quote` — must guard on event !== null
|
|
322
|
+
// instead of assuming it.
|
|
300
323
|
type ChannelCommandContext = {
|
|
301
324
|
live: LiveSession
|
|
302
|
-
event: InboundMessage
|
|
325
|
+
event: InboundMessage | null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export type ExecuteCommandResult =
|
|
329
|
+
| { kind: 'handled'; name: string }
|
|
330
|
+
| { kind: 'unknown-command'; name: string }
|
|
331
|
+
| { kind: 'no-live-session' }
|
|
332
|
+
| { kind: 'permission-denied' }
|
|
333
|
+
| { kind: 'ambiguous'; matchCount: number }
|
|
334
|
+
|
|
335
|
+
// Identifies who invoked an adapter-driven command. Required so the router
|
|
336
|
+
// can run the same channel.respond permission gate the text-prefix command
|
|
337
|
+
// path runs (isChannelRespondDenied in route()). Without it, a guest user
|
|
338
|
+
// in a public Slack channel could /stop an owner-created session that
|
|
339
|
+
// happened to be live, bypassing role gating entirely.
|
|
340
|
+
export type ExecuteCommandOptions = {
|
|
341
|
+
invokerId: string
|
|
303
342
|
}
|
|
304
343
|
|
|
305
344
|
export type SendSource = 'tool' | 'system'
|
|
@@ -345,11 +384,42 @@ export type ChannelRouter = {
|
|
|
345
384
|
registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
346
385
|
unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
347
386
|
fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
387
|
+
// Execute a command by name against an existing live session, bypassing
|
|
388
|
+
// the inbound classifier, engagement gate, debounce, and prompt queue.
|
|
389
|
+
// Used by adapters that receive commands through a native surface
|
|
390
|
+
// (Discord application-command interactions) rather than text. Gates
|
|
391
|
+
// the invoker on channel.respond — same permission gate the text-prefix
|
|
392
|
+
// command path runs — so a guest user cannot abort an owner's session
|
|
393
|
+
// by clicking the slash-command picker. Adapters MUST forward the
|
|
394
|
+
// invoker's platform-specific user id; without it the gate cannot
|
|
395
|
+
// identify the actor and resolves to 'guest' which denies. Returns:
|
|
396
|
+
// - handled: command ran
|
|
397
|
+
// - permission-denied: invoker lacks channel.respond
|
|
398
|
+
// - no-live-session: channel has no active session
|
|
399
|
+
// - ambiguous: multiple thread-keyed sessions in same chat (Slack);
|
|
400
|
+
// caller should refuse to act rather than abort an arbitrary one
|
|
401
|
+
// - unknown-command: name is not registered
|
|
402
|
+
executeCommand: (key: ChannelKey, name: string, options: ExecuteCommandOptions) => Promise<ExecuteCommandResult>
|
|
348
403
|
// Lowered self-aliases (configured + implicit dir-name). Adapters use
|
|
349
404
|
// this to anchor outbound threading on alias-only inbounds — see
|
|
350
405
|
// slack-bot-classify.ts. Read live so a reload of `alias` propagates
|
|
351
406
|
// to adapters without a restart.
|
|
352
407
|
getSelfAliases: () => readonly string[]
|
|
408
|
+
// Inject a `<system-reminder>` block addressed to a live channel session
|
|
409
|
+
// identified by `parentSessionId`. The reminder is rendered into the
|
|
410
|
+
// next turn's user-message body and triggers a drain even if the
|
|
411
|
+
// promptQueue is empty. Returns `delivered` when a matching live
|
|
412
|
+
// session was found and the reminder was queued, `no-live-session`
|
|
413
|
+
// otherwise. Used by the subagent-completion bridge in
|
|
414
|
+
// src/run/index.ts; safe for tests to call directly via a fake router.
|
|
415
|
+
injectSubagentCompletionReminder: (args: {
|
|
416
|
+
parentSessionId: string
|
|
417
|
+
subagent: string
|
|
418
|
+
taskId: string
|
|
419
|
+
ok: boolean
|
|
420
|
+
durationMs: number
|
|
421
|
+
error?: string
|
|
422
|
+
}) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
|
|
353
423
|
stop: () => Promise<void>
|
|
354
424
|
liveCount: () => number
|
|
355
425
|
__testing?: {
|
|
@@ -359,6 +429,34 @@ export type ChannelRouter = {
|
|
|
359
429
|
isTypingActive: (key: ChannelKey) => boolean
|
|
360
430
|
stopTyping: (key: ChannelKey) => Promise<void>
|
|
361
431
|
runIdleGc: () => Promise<void>
|
|
432
|
+
// Returns the seeded author state on the live session matching
|
|
433
|
+
// `key`, or undefined when no live session exists. Tests use this
|
|
434
|
+
// to pin the symmetric-seeding invariant between `lastTurnAuthorId`
|
|
435
|
+
// (string) and `lastTurnAuthorIds` (Set) at session creation —
|
|
436
|
+
// observable directly here rather than via a downstream sticky-
|
|
437
|
+
// credit grant test that would need to coordinate with multiple
|
|
438
|
+
// subsystems.
|
|
439
|
+
getLiveAuthorState: (key: ChannelKey) =>
|
|
440
|
+
| {
|
|
441
|
+
currentTurnAuthorId: string | null
|
|
442
|
+
currentTurnAuthorIds: readonly string[]
|
|
443
|
+
lastTurnAuthorId: string | null
|
|
444
|
+
lastTurnAuthorIds: readonly string[]
|
|
445
|
+
}
|
|
446
|
+
| undefined
|
|
447
|
+
// Returns a shallow copy of `live.originRef.current` for the live
|
|
448
|
+
// session matching `key`, or undefined when no live session exists.
|
|
449
|
+
// Exists so tests can assert on the per-turn origin that tool.before
|
|
450
|
+
// consumers would see — the origin is normally only observable
|
|
451
|
+
// indirectly via in-flight tool calls, which the fake session doesn't
|
|
452
|
+
// execute. The shallow copy detaches the top-level fields from
|
|
453
|
+
// `originRef` so a later turn replacing `originRef.current` doesn't
|
|
454
|
+
// change a captured assertion. Nested fields (`participants`,
|
|
455
|
+
// `membership`) are still shared by reference; in practice
|
|
456
|
+
// `updateParticipants` returns a fresh array rather than mutating in
|
|
457
|
+
// place, so observed snapshots are stable for the assertions tests
|
|
458
|
+
// make today. NOT a public router method.
|
|
459
|
+
getLiveOriginSnapshot: (key: ChannelKey) => SessionOrigin | undefined
|
|
362
460
|
}
|
|
363
461
|
}
|
|
364
462
|
|
|
@@ -763,6 +861,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
763
861
|
resolvedNames,
|
|
764
862
|
originRef,
|
|
765
863
|
promptQueue: [],
|
|
864
|
+
pendingSystemReminders: [],
|
|
766
865
|
contextBuffer: [],
|
|
767
866
|
draining: false,
|
|
768
867
|
debounceTimer: null,
|
|
@@ -774,7 +873,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
774
873
|
firstUnprocessedAt: 0,
|
|
775
874
|
currentTurnAuthorId: null,
|
|
776
875
|
currentTurnAuthorIds: new Set(),
|
|
777
|
-
|
|
876
|
+
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
877
|
+
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
878
|
+
// `grantStickyForReplyTargets` as the fallback when
|
|
879
|
+
// `currentTurnAuthorIds` is empty) are seeded TOGETHER from
|
|
880
|
+
// `triggeringAuthorId`. Seeding only the string would leave the
|
|
881
|
+
// Set empty for the cold-start reminder-only path, which is
|
|
882
|
+
// observable when the agent replies during that turn — `send()`
|
|
883
|
+
// would compute an empty `targetIds` and silently drop the
|
|
884
|
+
// sticky-credit grant for the seeded author. The two fields must
|
|
885
|
+
// stay in sync, so they are written in the same statement.
|
|
886
|
+
lastTurnAuthorIds: triggeringAuthorId !== undefined ? new Set([triggeringAuthorId]) : new Set(),
|
|
887
|
+
lastTurnAuthorId: triggeringAuthorId ?? null,
|
|
778
888
|
consecutiveAborts: 0,
|
|
779
889
|
consecutiveSends: new Map(),
|
|
780
890
|
lastSentText: new Map(),
|
|
@@ -989,12 +1099,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
989
1099
|
}
|
|
990
1100
|
}
|
|
991
1101
|
|
|
992
|
-
const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
|
|
1102
|
+
const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
|
|
993
1103
|
if (!live.hooks) return
|
|
994
1104
|
try {
|
|
995
1105
|
await live.hooks.runSessionTurnStart({
|
|
996
1106
|
sessionId: live.sessionId,
|
|
997
1107
|
agentDir: options.agentDir,
|
|
1108
|
+
userPrompt,
|
|
998
1109
|
origin: buildLiveOrigin(live),
|
|
999
1110
|
})
|
|
1000
1111
|
} catch (err) {
|
|
@@ -1045,6 +1156,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1045
1156
|
live.debounceTimer = null
|
|
1046
1157
|
live.firstUnprocessedAt = 0
|
|
1047
1158
|
live.promptQueue.length = 0
|
|
1159
|
+
live.pendingSystemReminders.length = 0
|
|
1048
1160
|
await stopTypingHeartbeat(live)
|
|
1049
1161
|
try {
|
|
1050
1162
|
await live.session.abort()
|
|
@@ -1058,7 +1170,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1058
1170
|
if (live.draining || live.destroyed) return
|
|
1059
1171
|
live.draining = true
|
|
1060
1172
|
try {
|
|
1061
|
-
while (live.promptQueue.length > 0 && !live.destroyed) {
|
|
1173
|
+
while ((live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) && !live.destroyed) {
|
|
1062
1174
|
live.typingTimedOut = false
|
|
1063
1175
|
// Heartbeat must run during generation as well as during debounce.
|
|
1064
1176
|
// Because new inbounds during a turn just push into promptQueue
|
|
@@ -1067,13 +1179,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1067
1179
|
startTypingHeartbeat(live)
|
|
1068
1180
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1069
1181
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1070
|
-
const
|
|
1182
|
+
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1183
|
+
const text = composeTurnPrompt(observed, batch, {
|
|
1184
|
+
loopGuardActive: live.loopGuardActive,
|
|
1185
|
+
systemReminders: reminders,
|
|
1186
|
+
})
|
|
1071
1187
|
|
|
1072
|
-
live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
|
|
1073
|
-
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1074
1188
|
if (batch.length > 0) {
|
|
1189
|
+
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1190
|
+
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1075
1191
|
live.consecutiveSends.clear()
|
|
1076
1192
|
live.lastSentText.clear()
|
|
1193
|
+
} else if (live.lastTurnAuthorId !== null) {
|
|
1194
|
+
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1195
|
+
// restore the author identity from the prior turn so author-
|
|
1196
|
+
// scoped role resolution still works on this turn. The drain
|
|
1197
|
+
// finally-block clears `currentTurnAuthorId` between turns, so a
|
|
1198
|
+
// reminder arriving while the session is idle would otherwise
|
|
1199
|
+
// strip `lastInboundAuthorId` from the tool.before origin and
|
|
1200
|
+
// demote roles like `slack:T0/C0 author:U_OWNER` to whichever
|
|
1201
|
+
// non-author rule matches — silently breaking the channel_reply
|
|
1202
|
+
// that the reminder is asking the agent to send. `lastTurnAuthorId`
|
|
1203
|
+
// tracks the LAST speaker of the prior batch (matching normal-
|
|
1204
|
+
// turn `batch[batch.length - 1]!.authorId` semantics) so a multi-
|
|
1205
|
+
// author prior turn like alice→bob restores `bob`, not alice.
|
|
1206
|
+
live.currentTurnAuthorId = live.lastTurnAuthorId
|
|
1207
|
+
live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
|
|
1077
1208
|
}
|
|
1078
1209
|
|
|
1079
1210
|
// Update the live origin holder so this turn's tool.before events
|
|
@@ -1090,7 +1221,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1090
1221
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1091
1222
|
const promptStart = now()
|
|
1092
1223
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1093
|
-
await fireSessionTurnStart(live)
|
|
1224
|
+
await fireSessionTurnStart(live, text)
|
|
1094
1225
|
try {
|
|
1095
1226
|
await live.session.prompt(text)
|
|
1096
1227
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -1105,6 +1236,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1105
1236
|
}
|
|
1106
1237
|
await fireSessionIdle(live)
|
|
1107
1238
|
live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
|
|
1239
|
+
if (live.currentTurnAuthorId !== null) {
|
|
1240
|
+
live.lastTurnAuthorId = live.currentTurnAuthorId
|
|
1241
|
+
}
|
|
1108
1242
|
}
|
|
1109
1243
|
} finally {
|
|
1110
1244
|
live.draining = false
|
|
@@ -1706,6 +1840,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1706
1840
|
if (live.destroyed) continue
|
|
1707
1841
|
if (live.draining) continue
|
|
1708
1842
|
if (live.promptQueue.length > 0) continue
|
|
1843
|
+
// pendingSystemReminders is checked alongside promptQueue because both
|
|
1844
|
+
// represent pending work that drain() will process. Today's only
|
|
1845
|
+
// populator (injectSubagentCompletionReminder) also fires drain()
|
|
1846
|
+
// synchronously, which sets draining=true and shadows this guard via
|
|
1847
|
+
// the line above — but the guard exists to keep the invariant honest
|
|
1848
|
+
// for any future caller that queues a reminder without immediately
|
|
1849
|
+
// waking the drain loop.
|
|
1850
|
+
if (live.pendingSystemReminders.length > 0) continue
|
|
1709
1851
|
if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
|
|
1710
1852
|
victims.push(live)
|
|
1711
1853
|
}
|
|
@@ -1733,6 +1875,87 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1733
1875
|
}
|
|
1734
1876
|
}
|
|
1735
1877
|
|
|
1878
|
+
const executeCommand = async (
|
|
1879
|
+
key: ChannelKey,
|
|
1880
|
+
name: string,
|
|
1881
|
+
options: ExecuteCommandOptions,
|
|
1882
|
+
): Promise<ExecuteCommandResult> => {
|
|
1883
|
+
const lowered = name.toLowerCase()
|
|
1884
|
+
if (!commands.has(lowered)) {
|
|
1885
|
+
return { kind: 'unknown-command', name: lowered }
|
|
1886
|
+
}
|
|
1887
|
+
// Permission gate runs BEFORE the live-session lookup so a guest user
|
|
1888
|
+
// invoking /stop on a non-existent session gets 'permission-denied'
|
|
1889
|
+
// (consistent answer regardless of session state) rather than leaking
|
|
1890
|
+
// session presence via the 'no-live-session' vs 'permission-denied'
|
|
1891
|
+
// distinction.
|
|
1892
|
+
const partial: SessionOrigin = {
|
|
1893
|
+
kind: 'channel',
|
|
1894
|
+
adapter: key.adapter,
|
|
1895
|
+
workspace: key.workspace,
|
|
1896
|
+
chat: key.chat,
|
|
1897
|
+
thread: key.thread,
|
|
1898
|
+
lastInboundAuthorId: options.invokerId,
|
|
1899
|
+
}
|
|
1900
|
+
if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
|
|
1901
|
+
return { kind: 'permission-denied' }
|
|
1902
|
+
}
|
|
1903
|
+
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
1904
|
+
if (resolved.kind === 'none') {
|
|
1905
|
+
return { kind: 'no-live-session' }
|
|
1906
|
+
}
|
|
1907
|
+
if (resolved.kind === 'ambiguous') {
|
|
1908
|
+
return { kind: 'ambiguous', matchCount: resolved.count }
|
|
1909
|
+
}
|
|
1910
|
+
const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
|
|
1911
|
+
if (result.kind === 'handled') {
|
|
1912
|
+
return { kind: 'handled', name: result.name }
|
|
1913
|
+
}
|
|
1914
|
+
// commands.execute can only return not-command (impossible — we pass a
|
|
1915
|
+
// leading slash), unknown-command (impossible — we just checked has()),
|
|
1916
|
+
// or handled. Any other outcome is a bug.
|
|
1917
|
+
return { kind: 'unknown-command', name: lowered }
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const injectSubagentCompletionReminder = (args: {
|
|
1921
|
+
parentSessionId: string
|
|
1922
|
+
subagent: string
|
|
1923
|
+
taskId: string
|
|
1924
|
+
ok: boolean
|
|
1925
|
+
durationMs: number
|
|
1926
|
+
error?: string
|
|
1927
|
+
}): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
|
|
1928
|
+
for (const live of liveSessions.values()) {
|
|
1929
|
+
if (live.destroyed) continue
|
|
1930
|
+
if (live.sessionId !== args.parentSessionId) continue
|
|
1931
|
+
const text = renderSubagentCompletionReminder({
|
|
1932
|
+
subagent: args.subagent,
|
|
1933
|
+
taskId: args.taskId,
|
|
1934
|
+
ok: args.ok,
|
|
1935
|
+
durationMs: args.durationMs,
|
|
1936
|
+
...(args.error !== undefined ? { error: args.error } : {}),
|
|
1937
|
+
channel: true,
|
|
1938
|
+
})
|
|
1939
|
+
live.pendingSystemReminders.push(text)
|
|
1940
|
+
logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
|
|
1941
|
+
// Wake the drain loop. If a turn is already in flight, the wakeup is
|
|
1942
|
+
// a no-op because drain() will pick up the reminder on its next
|
|
1943
|
+
// iteration (it now gates on promptQueue OR pendingSystemReminders).
|
|
1944
|
+
// If the session is idle, fire drain() immediately rather than going
|
|
1945
|
+
// through the debounce path — the reminder is not a user inbound,
|
|
1946
|
+
// so the "coalesce nearby inbounds" rationale for debouncing does
|
|
1947
|
+
// not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
|
|
1948
|
+
// semantics: the channel router doesn't have a `delivery: interrupt`
|
|
1949
|
+
// mechanism (no in-flight abort during a turn), but firing drain()
|
|
1950
|
+
// immediately is the equivalent for an idle session.
|
|
1951
|
+
if (!live.draining) {
|
|
1952
|
+
void drain(live)
|
|
1953
|
+
}
|
|
1954
|
+
return { kind: 'delivered', keyId: live.keyId }
|
|
1955
|
+
}
|
|
1956
|
+
return { kind: 'no-live-session' }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1736
1959
|
return {
|
|
1737
1960
|
route,
|
|
1738
1961
|
send,
|
|
@@ -1752,7 +1975,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1752
1975
|
registerFetchAttachment,
|
|
1753
1976
|
unregisterFetchAttachment,
|
|
1754
1977
|
fetchAttachment,
|
|
1978
|
+
executeCommand,
|
|
1755
1979
|
getSelfAliases: computeSelfAliases,
|
|
1980
|
+
injectSubagentCompletionReminder,
|
|
1756
1981
|
stop,
|
|
1757
1982
|
liveCount: () => liveSessions.size,
|
|
1758
1983
|
__testing: {
|
|
@@ -1796,6 +2021,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1796
2021
|
await stopTypingHeartbeat(live)
|
|
1797
2022
|
},
|
|
1798
2023
|
runIdleGc,
|
|
2024
|
+
getLiveOriginSnapshot: (key: ChannelKey) => {
|
|
2025
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
2026
|
+
const origin = live?.originRef.current
|
|
2027
|
+
if (origin === undefined) return undefined
|
|
2028
|
+
return { ...origin }
|
|
2029
|
+
},
|
|
2030
|
+
getLiveAuthorState: (key: ChannelKey) => {
|
|
2031
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
2032
|
+
if (live === undefined) return undefined
|
|
2033
|
+
return {
|
|
2034
|
+
currentTurnAuthorId: live.currentTurnAuthorId,
|
|
2035
|
+
currentTurnAuthorIds: Array.from(live.currentTurnAuthorIds),
|
|
2036
|
+
lastTurnAuthorId: live.lastTurnAuthorId,
|
|
2037
|
+
lastTurnAuthorIds: Array.from(live.lastTurnAuthorIds),
|
|
2038
|
+
}
|
|
2039
|
+
},
|
|
1799
2040
|
},
|
|
1800
2041
|
}
|
|
1801
2042
|
}
|
|
@@ -1803,27 +2044,50 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1803
2044
|
function composeTurnPrompt(
|
|
1804
2045
|
observed: readonly ObservedInbound[],
|
|
1805
2046
|
batch: readonly QueuedInbound[],
|
|
1806
|
-
state: { loopGuardActive: boolean } = { loopGuardActive: false },
|
|
2047
|
+
state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
|
|
1807
2048
|
): string {
|
|
1808
2049
|
const parts: string[] = []
|
|
2050
|
+
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2051
|
+
// because they are typically what triggered the drain — when the prompt
|
|
2052
|
+
// queue is empty and the only thing in this iteration is a reminder, the
|
|
2053
|
+
// model needs to see the reminder before any optional context. The
|
|
2054
|
+
// reminder block is self-fenced by its <system-reminder> tags, so no
|
|
2055
|
+
// extra framing is needed and the model already learns this shape from
|
|
2056
|
+
// the TUI path; channel sessions see the same tags.
|
|
2057
|
+
if (state.systemReminders && state.systemReminders.length > 0) {
|
|
2058
|
+
for (const reminder of state.systemReminders) {
|
|
2059
|
+
parts.push(reminder)
|
|
2060
|
+
}
|
|
2061
|
+
parts.push('')
|
|
2062
|
+
}
|
|
1809
2063
|
// Loop-guard notice lives in the user-turn text (recomposed every drain)
|
|
1810
2064
|
// rather than in the system prompt so it does not invalidate the
|
|
1811
2065
|
// prompt-prefix cache. The cached prefix covers system + tools + earlier
|
|
1812
2066
|
// turns; the current user-turn suffix is non-cacheable by design, so
|
|
1813
2067
|
// adding a section here is cache-neutral.
|
|
1814
2068
|
//
|
|
1815
|
-
// SYSTEM MESSAGE convention: any runtime-injected block in the user
|
|
1816
|
-
// that is NOT from a chat participant
|
|
1817
|
-
// `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
|
|
1818
|
-
// rules (`---`)
|
|
1819
|
-
//
|
|
2069
|
+
// SYSTEM MESSAGE convention: any runtime-injected block in the user
|
|
2070
|
+
// turn that is NOT from a chat participant MUST use the
|
|
2071
|
+
// `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
|
|
2072
|
+
// horizontal rules (`---`) — the loop-guard block below is the
|
|
2073
|
+
// canonical example. This is structurally distinct from the H2
|
|
2074
|
+
// sections used for actual conversation content (`## Recent context`,
|
|
1820
2075
|
// `## Current message`). Without the fencing, models — especially
|
|
1821
2076
|
// persona-rich ones like Kimi — read the heading as a human-authored
|
|
1822
2077
|
// instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
|
|
1823
|
-
// bracketed marker plus the explicit "Do not acknowledge or reply to
|
|
1824
|
-
// notice" line is the trust boundary that prevents this. New
|
|
1825
|
-
// notices (rate-limit, schema-mismatch, abort signals, etc.)
|
|
1826
|
-
// this
|
|
2078
|
+
// bracketed marker plus the explicit "Do not acknowledge or reply to
|
|
2079
|
+
// this notice" line is the trust boundary that prevents this. New
|
|
2080
|
+
// runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
|
|
2081
|
+
// MUST follow this convention.
|
|
2082
|
+
//
|
|
2083
|
+
// ONE narrow exception exists: subagent-completion reminders use
|
|
2084
|
+
// `<system-reminder>...</system-reminder>` tags (prepended above) for
|
|
2085
|
+
// parity with the TUI path's identical tagging (see
|
|
2086
|
+
// `renderSubagentCompletionReminder` in
|
|
2087
|
+
// `src/agent/subagent-completion-reminder.ts`) so the model sees the
|
|
2088
|
+
// same shape across origins. The exception is scoped to that single
|
|
2089
|
+
// case: do NOT extend it to new notice types. Anything that is not
|
|
2090
|
+
// a true subagent-style completion ping uses framing 1.
|
|
1827
2091
|
if (state.loopGuardActive) {
|
|
1828
2092
|
parts.push(
|
|
1829
2093
|
'---',
|
|
@@ -1912,6 +2176,52 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
|
|
|
1912
2176
|
return `${chat}:${thread ?? ''}`
|
|
1913
2177
|
}
|
|
1914
2178
|
|
|
2179
|
+
export type ResolveLiveSessionResult =
|
|
2180
|
+
| { kind: 'found'; session: LiveSession }
|
|
2181
|
+
| { kind: 'none' }
|
|
2182
|
+
| { kind: 'ambiguous'; count: number }
|
|
2183
|
+
|
|
2184
|
+
// Lookup policy for adapter-driven commands. Exact-key match always wins.
|
|
2185
|
+
// On miss, fall back to (adapter, workspace, chat) without thread — but
|
|
2186
|
+
// only when EXACTLY ONE non-destroyed candidate exists. Ambiguous matches
|
|
2187
|
+
// return 'ambiguous' so the caller can refuse to act rather than abort an
|
|
2188
|
+
// arbitrary session.
|
|
2189
|
+
//
|
|
2190
|
+
// Why the fallback: Slack slash commands carry channel_id but no thread_ts,
|
|
2191
|
+
// so a slash invocation from a thread-keyed live session would otherwise
|
|
2192
|
+
// report no-live-session. Discord doesn't hit this — Discord treats threads
|
|
2193
|
+
// as channels, so the exact-key path already resolves.
|
|
2194
|
+
//
|
|
2195
|
+
// Why ambiguity-rejection: "first match wins" map-iteration semantics would
|
|
2196
|
+
// abort an arbitrary thread when multiple thread-keyed sessions coexist in
|
|
2197
|
+
// one channel (plausible on Slack: bot mentioned in multiple threads). The
|
|
2198
|
+
// user's slash command picker doesn't know about threads; we don't know
|
|
2199
|
+
// which they meant; refusing is safer than guessing.
|
|
2200
|
+
export function resolveLiveSessionForCommand(
|
|
2201
|
+
liveSessions: ReadonlyMap<string, LiveSession>,
|
|
2202
|
+
key: ChannelKey,
|
|
2203
|
+
): ResolveLiveSessionResult {
|
|
2204
|
+
const exact = liveSessions.get(channelKeyId(key))
|
|
2205
|
+
if (exact && !exact.destroyed) return { kind: 'found', session: exact }
|
|
2206
|
+
|
|
2207
|
+
const matches: LiveSession[] = []
|
|
2208
|
+
for (const candidate of liveSessions.values()) {
|
|
2209
|
+
if (candidate.destroyed) continue
|
|
2210
|
+
if (
|
|
2211
|
+
candidate.key.adapter === key.adapter &&
|
|
2212
|
+
candidate.key.workspace === key.workspace &&
|
|
2213
|
+
candidate.key.chat === key.chat
|
|
2214
|
+
) {
|
|
2215
|
+
matches.push(candidate)
|
|
2216
|
+
if (matches.length > 1) {
|
|
2217
|
+
return { kind: 'ambiguous', count: matches.length }
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (matches.length === 1) return { kind: 'found', session: matches[0]! }
|
|
2222
|
+
return { kind: 'none' }
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1915
2225
|
function normalizeSendText(text: string | undefined): string | undefined {
|
|
1916
2226
|
if (text === undefined) return undefined
|
|
1917
2227
|
if (text === '') return undefined
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseSubagentCompletedPayload } from '@/agent/subagent-completion-reminder'
|
|
2
|
+
import type { Stream } from '@/stream'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from './router'
|
|
5
|
+
|
|
6
|
+
export type SubagentCompletionBridgeLogger = {
|
|
7
|
+
info: (msg: string) => void
|
|
8
|
+
warn: (msg: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SubagentCompletionBridgeOptions = {
|
|
12
|
+
stream: Stream
|
|
13
|
+
router: Pick<ChannelRouter, 'injectSubagentCompletionReminder'>
|
|
14
|
+
logger?: SubagentCompletionBridgeLogger
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SubagentCompletionBridge = {
|
|
18
|
+
stop: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const consoleLogger: SubagentCompletionBridgeLogger = {
|
|
22
|
+
info: (msg) => console.log(msg),
|
|
23
|
+
warn: (msg) => console.warn(msg),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Bridges `subagent.completed` broadcasts on the in-process Stream into a
|
|
27
|
+
// channel router call so the channel session that spawned the subagent
|
|
28
|
+
// gets woken up with a `<system-reminder>` when the subagent finishes.
|
|
29
|
+
//
|
|
30
|
+
// Two-bridges-for-two-surfaces design (matches the TUI side at
|
|
31
|
+
// src/server/index.ts `routeSubagentCompletionReminder`):
|
|
32
|
+
//
|
|
33
|
+
// - TUI sessions: the WS server subscribes to broadcasts on the same
|
|
34
|
+
// stream and re-publishes the reminder as `target: { kind: 'session' }`
|
|
35
|
+
// so the per-session drain loop in the server picks it up. Lookup is
|
|
36
|
+
// by sessionId (which is `state.sessionFileId`).
|
|
37
|
+
//
|
|
38
|
+
// - Channel sessions: this bridge subscribes and calls
|
|
39
|
+
// `router.injectSubagentCompletionReminder` because the channel router
|
|
40
|
+
// owns its own per-key drain loop and doesn't use the stream's
|
|
41
|
+
// session-keyed target.
|
|
42
|
+
//
|
|
43
|
+
// `parentSessionId` matching is the same on both sides: when a channel
|
|
44
|
+
// session spawns a subagent via `spawn_subagent`, the tool captures
|
|
45
|
+
// `sessionManager.getSessionId()` and publishes it as the broadcast's
|
|
46
|
+
// `parentSessionId`. That id is exactly what the router stores on each
|
|
47
|
+
// `LiveSession`, so the lookup is O(N) over live sessions with N small
|
|
48
|
+
// (one per active conversation).
|
|
49
|
+
//
|
|
50
|
+
// On `no-live-session`, we silently drop. Three observable paths reach
|
|
51
|
+
// this branch in production:
|
|
52
|
+
//
|
|
53
|
+
// - The parent session was GC'd by the idle-eviction tick
|
|
54
|
+
// (SESSION_IDLE_MS) while the subagent was running.
|
|
55
|
+
// - The parent session rolled over (SESSION_FRESHNESS_TTL_MS) when a
|
|
56
|
+
// new inbound arrived during a long-running subagent — the channel
|
|
57
|
+
// conversation continues on the new sessionId, but the broadcast
|
|
58
|
+
// still carries the old one.
|
|
59
|
+
// - The parent was a TUI session (the TUI bridge in
|
|
60
|
+
// src/server/index.ts handles it).
|
|
61
|
+
//
|
|
62
|
+
// The right fix for the first two paths is for the broadcast to carry
|
|
63
|
+
// the channel-key coordinate `{ adapter, workspace, chat, thread }` so
|
|
64
|
+
// the bridge can fall back to "any live session for the same channel
|
|
65
|
+
// key" when the exact sessionId no longer matches. That requires
|
|
66
|
+
// extending the broadcast payload (consumed by TUI and channel paths)
|
|
67
|
+
// and gating spawn_subagent to capture the origin coordinates — both
|
|
68
|
+
// non-trivial. Deferred until we see this drop pattern in production
|
|
69
|
+
// logs; the info log line below makes the case diagnosable from logs
|
|
70
|
+
// alone.
|
|
71
|
+
export function createSubagentCompletionBridge(options: SubagentCompletionBridgeOptions): SubagentCompletionBridge {
|
|
72
|
+
const logger = options.logger ?? consoleLogger
|
|
73
|
+
const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
74
|
+
const parsed = parseSubagentCompletedPayload(msg.payload)
|
|
75
|
+
if (parsed === null) return
|
|
76
|
+
const result = options.router.injectSubagentCompletionReminder(parsed)
|
|
77
|
+
if (result.kind === 'no-live-session') {
|
|
78
|
+
logger.info(
|
|
79
|
+
`[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
return { stop: unsubscribe }
|
|
84
|
+
}
|
package/src/cli/builtins.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ const main = defineCommand({
|
|
|
22
22
|
status: () => import('./status').then((m) => m.statusCommand),
|
|
23
23
|
reload: () => import('./reload').then((m) => m.reload),
|
|
24
24
|
logs: () => import('./logs').then((m) => m.logsCommand),
|
|
25
|
+
inspect: () => import('./inspect').then((m) => m.inspectCommand),
|
|
25
26
|
shell: () => import('./shell').then((m) => m.shellCommand),
|
|
26
27
|
compose: () => import('./compose').then((m) => m.composeCommand),
|
|
27
28
|
channel: () => import('./channel').then((m) => m.channelCommand),
|