typeclaw 0.15.2 → 0.17.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 +1 -1
- package/src/agent/index.ts +35 -2
- package/src/agent/plugin-tools.ts +38 -0
- package/src/agent/session-meta.ts +6 -2
- package/src/agent/session-origin.ts +111 -14
- package/src/agent/subagents.ts +6 -1
- package/src/agent/system-prompt.ts +41 -32
- package/src/agent/tools/channel-reply.ts +3 -2
- package/src/agent/tools/grant-role.ts +214 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
- package/src/bundled-plugins/memory/index.ts +25 -6
- package/src/bundled-plugins/security/index.ts +12 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
- package/src/channels/adapters/github/inbound.ts +54 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/router.ts +150 -37
- package/src/cli/inspect.ts +20 -9
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/index.ts +24 -42
- package/src/init/paths.ts +1 -0
- package/src/init/run-owner-claim.ts +21 -3
- package/src/inspect/label.ts +2 -0
- package/src/inspect/live.ts +6 -1
- package/src/inspect/render.ts +8 -2
- package/src/inspect/replay.ts +6 -1
- package/src/inspect/types.ts +4 -1
- package/src/permissions/builtins.ts +22 -0
- package/src/permissions/grant.ts +92 -16
- package/src/permissions/index.ts +8 -2
- package/src/permissions/permissions.ts +16 -0
- package/src/permissions/resolve.ts +10 -0
- package/src/plugin/types.ts +12 -0
- package/src/role-claim/index.ts +1 -0
- package/src/role-claim/reload-after-claim.ts +34 -0
- package/src/run/channel-session-factory.ts +6 -1
- package/src/run/index.ts +18 -1
- package/src/sandbox/build.ts +51 -1
- package/src/sandbox/hidden-paths.ts +41 -0
- package/src/sandbox/index.ts +2 -1
- package/src/sandbox/policy.ts +15 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
- package/src/skills/typeclaw-permissions/SKILL.md +11 -3
- package/src/skills/typeclaw-skills/SKILL.md +3 -1
- package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
- package/src/usage/report.ts +4 -0
- package/src/usage/scan.ts +1 -1
package/src/channels/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
4
4
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
5
|
|
|
6
|
-
import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
6
|
+
import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
@@ -164,6 +164,19 @@ export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
|
|
|
164
164
|
// instead of awaiting the same dead promise forever.
|
|
165
165
|
export const ENSURE_LIVE_TIMEOUT_MS = 30_000
|
|
166
166
|
|
|
167
|
+
// Thrown by ensureLive() when a teardown (roles reload or shutdown) raced
|
|
168
|
+
// ahead of an in-flight creation. route() has no special handling — it
|
|
169
|
+
// propagates to the adapter's outer catch, dropping this one inbound. The
|
|
170
|
+
// next inbound creates a fresh, post-reload session, which is the intended
|
|
171
|
+
// outcome: a message that arrived mid-reload is cheap to drop, far cheaper
|
|
172
|
+
// than answering it through a session built with the stale role.
|
|
173
|
+
export class StaleLiveSessionError extends Error {
|
|
174
|
+
constructor(keyId: string) {
|
|
175
|
+
super(`[channels] ${keyId}: live session creation raced a teardown; discarded`)
|
|
176
|
+
this.name = 'StaleLiveSessionError'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
// Per-callback ceilings inside the ensureLive chain. The outer watchdog
|
|
168
181
|
// catches the worst case, but per-step timeouts give better log
|
|
169
182
|
// attribution (which step hung) AND graceful degradation: a hung name
|
|
@@ -562,6 +575,7 @@ export type ChannelRouter = {
|
|
|
562
575
|
| { kind: 'recorded-after-send'; keyId: string }
|
|
563
576
|
| { kind: 'no-live-session' }
|
|
564
577
|
stop: () => Promise<void>
|
|
578
|
+
tearDownAllLive: () => Promise<void>
|
|
565
579
|
liveCount: () => number
|
|
566
580
|
__testing?: {
|
|
567
581
|
flushDebounce: (key: ChannelKey) => Promise<void>
|
|
@@ -691,6 +705,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
691
705
|
const stream = options.stream
|
|
692
706
|
const liveSessions = new Map<string, LiveSession>()
|
|
693
707
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
708
|
+
// Bumped by tearDownAllLive() and stop() before they tear sessions down. An
|
|
709
|
+
// in-flight ensureLive() captures the value at creation start and re-checks
|
|
710
|
+
// it right before installing into liveSessions; if it changed, a teardown
|
|
711
|
+
// raced ahead of this creation (e.g. a roles.match reload), so the session
|
|
712
|
+
// was built with stale role context and must self-dispose instead of
|
|
713
|
+
// installing — otherwise it would reintroduce the very staleness the
|
|
714
|
+
// teardown was meant to clear.
|
|
715
|
+
let liveGeneration = 0
|
|
694
716
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
695
717
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
696
718
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
@@ -909,6 +931,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
909
931
|
const inFlight = creating.get(keyId)
|
|
910
932
|
if (inFlight) return inFlight
|
|
911
933
|
|
|
934
|
+
const generation = liveGeneration
|
|
935
|
+
|
|
912
936
|
const promise = (async () => {
|
|
913
937
|
await ensureLoaded()
|
|
914
938
|
const record = mappings ? findRecord(mappings, key) : undefined
|
|
@@ -1073,6 +1097,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1073
1097
|
live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
|
|
1074
1098
|
installChannelReplyTerminalHook(live)
|
|
1075
1099
|
installChannelOutputCap(live)
|
|
1100
|
+
|
|
1101
|
+
// A teardown (roles reload / shutdown) ran while this session was being
|
|
1102
|
+
// built, so it carries stale role context. Dispose it instead of
|
|
1103
|
+
// installing — installing here is the exact window the race exploits.
|
|
1104
|
+
if (generation !== liveGeneration) {
|
|
1105
|
+
logger.info(
|
|
1106
|
+
`[channels] ${keyId}: discarding session created across a teardown (gen ${generation} → ${liveGeneration})`,
|
|
1107
|
+
)
|
|
1108
|
+
await tearDownLive(live)
|
|
1109
|
+
throw new StaleLiveSessionError(keyId)
|
|
1110
|
+
}
|
|
1076
1111
|
liveSessions.set(keyId, live)
|
|
1077
1112
|
|
|
1078
1113
|
if (isColdStart) {
|
|
@@ -1427,11 +1462,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1427
1462
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1428
1463
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1429
1464
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1430
|
-
const text = composeTurnPrompt(observed, batch, {
|
|
1431
|
-
adapter: live.key.adapter,
|
|
1432
|
-
loopGuardActive: live.loopGuardActive,
|
|
1433
|
-
systemReminders: reminders,
|
|
1434
|
-
})
|
|
1435
1465
|
|
|
1436
1466
|
if (batch.length > 0) {
|
|
1437
1467
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
@@ -1457,12 +1487,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1457
1487
|
}
|
|
1458
1488
|
|
|
1459
1489
|
// Update the live origin holder so this turn's tool.before events
|
|
1460
|
-
// carry the current actor's id
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1490
|
+
// carry the current actor's id, and resolve the live role from it for
|
|
1491
|
+
// the per-turn <your-role> anchor below. Done BEFORE composeTurnPrompt
|
|
1492
|
+
// so the anchor reflects the speaker of THIS turn, not the session-
|
|
1493
|
+
// creation snapshot the system prompt still renders. Permission gating
|
|
1494
|
+
// off `lastInboundAuthorId` happens in the tool layer and sees the same
|
|
1464
1495
|
// live value.
|
|
1465
1496
|
live.originRef.current = buildLiveOrigin(live)
|
|
1497
|
+
const liveRole = permissions.describe(live.originRef.current).role
|
|
1498
|
+
|
|
1499
|
+
const text = composeTurnPrompt(observed, batch, {
|
|
1500
|
+
adapter: live.key.adapter,
|
|
1501
|
+
loopGuardActive: live.loopGuardActive,
|
|
1502
|
+
systemReminders: reminders,
|
|
1503
|
+
role: liveRole,
|
|
1504
|
+
})
|
|
1466
1505
|
|
|
1467
1506
|
// Bracketing logs around the LLM call so a hung prompt() is
|
|
1468
1507
|
// diagnosable from logs alone (we see prompting without prompted).
|
|
@@ -1628,6 +1667,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1628
1667
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1629
1668
|
return
|
|
1630
1669
|
}
|
|
1670
|
+
if (isSessionControlDenied(event)) {
|
|
1671
|
+
logger.info(
|
|
1672
|
+
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
|
|
1673
|
+
)
|
|
1674
|
+
return
|
|
1675
|
+
}
|
|
1631
1676
|
const existingLive = liveSessions.get(keyId)
|
|
1632
1677
|
if (!existingLive || existingLive.destroyed) {
|
|
1633
1678
|
logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
|
|
@@ -1710,17 +1755,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1710
1755
|
scheduleDebouncedDrain(live)
|
|
1711
1756
|
}
|
|
1712
1757
|
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1758
|
+
const inboundAuthorOrigin = (event: InboundMessage): SessionOrigin => ({
|
|
1759
|
+
kind: 'channel',
|
|
1760
|
+
adapter: event.adapter,
|
|
1761
|
+
workspace: event.workspace,
|
|
1762
|
+
chat: event.chat,
|
|
1763
|
+
thread: event.thread,
|
|
1764
|
+
lastInboundAuthorId: event.authorId,
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
const isChannelRespondDenied = (event: InboundMessage): boolean =>
|
|
1768
|
+
!permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.channelRespond)
|
|
1769
|
+
|
|
1770
|
+
// Gated separately from channelRespond so a respond-capable guest (an
|
|
1771
|
+
// operator can grant guest channelRespond for masked stranger turns)
|
|
1772
|
+
// cannot /stop another speaker's in-flight turn. session.control is
|
|
1773
|
+
// member-and-up by default.
|
|
1774
|
+
const isSessionControlDenied = (event: InboundMessage): boolean =>
|
|
1775
|
+
!permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
|
|
1724
1776
|
|
|
1725
1777
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1726
1778
|
if (!event.authorIsBot) {
|
|
@@ -2199,9 +2251,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2199
2251
|
return
|
|
2200
2252
|
}
|
|
2201
2253
|
|
|
2202
|
-
// `source` distinguishes the
|
|
2203
|
-
// - 'leaf': the assistant message IS the leaf
|
|
2204
|
-
// ended its turn with text but forgot to
|
|
2254
|
+
// `source` distinguishes the three recovery shapes for log triage:
|
|
2255
|
+
// - 'leaf': the assistant message IS the leaf with stopReason 'stop'
|
|
2256
|
+
// (existing behavior; model ended its turn with text but forgot to
|
|
2257
|
+
// call channel_reply).
|
|
2258
|
+
// - 'mid-turn': the assistant message IS the leaf with stopReason
|
|
2259
|
+
// 'toolUse'; the model narrated a reply, committed to a tool plan, and
|
|
2260
|
+
// the turn ended before a follow-up that would have called a channel
|
|
2261
|
+
// tool was persisted. The narration is the only user-facing text.
|
|
2205
2262
|
// - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
|
|
2206
2263
|
// and the assistant message lives upstream in the branch. This is the
|
|
2207
2264
|
// Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
|
|
@@ -2325,6 +2382,27 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2325
2382
|
const stop = async (): Promise<void> => {
|
|
2326
2383
|
if (gcTimer) clearInterval(gcTimer)
|
|
2327
2384
|
gcTimer = null
|
|
2385
|
+
liveGeneration++
|
|
2386
|
+
const all = Array.from(liveSessions.values())
|
|
2387
|
+
liveSessions.clear()
|
|
2388
|
+
for (const live of all) {
|
|
2389
|
+
await tearDownLive(live)
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Drops every in-memory session but KEEPS the on-disk records, so the next
|
|
2394
|
+
// inbound per channel rehydrates the same transcript through a fresh
|
|
2395
|
+
// createSession() — which re-renders the frozen system-prompt role block.
|
|
2396
|
+
// This is how a `roles.<name>.match` reload reaches live channel sessions.
|
|
2397
|
+
// Unlike stop() it leaves the GC timer running; unlike stale-rollover it
|
|
2398
|
+
// keeps the sessionId, so history survives.
|
|
2399
|
+
//
|
|
2400
|
+
// Bumping liveGeneration BEFORE the snapshot is what makes this race-free:
|
|
2401
|
+
// a session mid-creation (in `creating` but not yet in `liveSessions`) won't
|
|
2402
|
+
// appear in the snapshot below, but it captured the old generation and will
|
|
2403
|
+
// self-dispose at its install guard instead of resurrecting stale role state.
|
|
2404
|
+
const tearDownAllLive = async (): Promise<void> => {
|
|
2405
|
+
liveGeneration++
|
|
2328
2406
|
const all = Array.from(liveSessions.values())
|
|
2329
2407
|
liveSessions.clear()
|
|
2330
2408
|
for (const live of all) {
|
|
@@ -2341,11 +2419,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2341
2419
|
if (!commands.has(lowered)) {
|
|
2342
2420
|
return { kind: 'unknown-command', name: lowered }
|
|
2343
2421
|
}
|
|
2344
|
-
//
|
|
2345
|
-
//
|
|
2346
|
-
//
|
|
2347
|
-
// session
|
|
2348
|
-
// distinction.
|
|
2422
|
+
// Gates on session.control (not channel.respond) so a respond-capable
|
|
2423
|
+
// guest cannot abort another speaker's turn. Runs BEFORE the live-session
|
|
2424
|
+
// lookup so an unauthorized invoker gets 'permission-denied' regardless of
|
|
2425
|
+
// session state, rather than leaking session presence via the
|
|
2426
|
+
// 'no-live-session' vs 'permission-denied' distinction.
|
|
2349
2427
|
const partial: SessionOrigin = {
|
|
2350
2428
|
kind: 'channel',
|
|
2351
2429
|
adapter: key.adapter,
|
|
@@ -2354,7 +2432,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2354
2432
|
thread: key.thread,
|
|
2355
2433
|
lastInboundAuthorId: options.invokerId,
|
|
2356
2434
|
}
|
|
2357
|
-
if (!permissions.has(partial, CORE_PERMISSIONS.
|
|
2435
|
+
if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
|
|
2358
2436
|
return { kind: 'permission-denied' }
|
|
2359
2437
|
}
|
|
2360
2438
|
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
@@ -2467,6 +2545,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2467
2545
|
injectSubagentCompletionReminder,
|
|
2468
2546
|
markTurnSkipped,
|
|
2469
2547
|
stop,
|
|
2548
|
+
tearDownAllLive,
|
|
2470
2549
|
liveCount: () => liveSessions.size,
|
|
2471
2550
|
__testing: {
|
|
2472
2551
|
flushDebounce: async (key: ChannelKey) => {
|
|
@@ -2534,13 +2613,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2534
2613
|
function composeTurnPrompt(
|
|
2535
2614
|
observed: readonly ObservedInbound[],
|
|
2536
2615
|
batch: readonly QueuedInbound[],
|
|
2537
|
-
state: {
|
|
2616
|
+
state: {
|
|
2617
|
+
adapter?: AdapterId
|
|
2618
|
+
loopGuardActive: boolean
|
|
2619
|
+
systemReminders?: readonly string[]
|
|
2620
|
+
now?: Date
|
|
2621
|
+
role?: string
|
|
2622
|
+
} = {
|
|
2538
2623
|
loopGuardActive: false,
|
|
2539
2624
|
},
|
|
2540
2625
|
): string {
|
|
2541
2626
|
const adapter = state.adapter ?? 'discord-bot'
|
|
2542
2627
|
const parts: string[] = []
|
|
2543
2628
|
parts.push(renderTurnTimeAnchor(state.now), '')
|
|
2629
|
+
const roleAnchor = state.role !== undefined ? renderTurnRoleAnchor(state.role) : undefined
|
|
2630
|
+
if (roleAnchor !== undefined) parts.push(roleAnchor, '')
|
|
2544
2631
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2545
2632
|
// because they are typically what triggered the drain — when the prompt
|
|
2546
2633
|
// queue is empty and the only thing in this iteration is a reminder, the
|
|
@@ -3001,7 +3088,7 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
3001
3088
|
// assistant message — i.e., text the user should see but didn't, because the
|
|
3002
3089
|
// model failed to call `channel_reply`/`channel_send` before its turn ended.
|
|
3003
3090
|
//
|
|
3004
|
-
//
|
|
3091
|
+
// Three recovery shapes:
|
|
3005
3092
|
//
|
|
3006
3093
|
// - source: 'leaf'
|
|
3007
3094
|
// The leaf entry IS an assistant message with `stopReason === 'stop'`.
|
|
@@ -3009,6 +3096,20 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
3009
3096
|
// tool. Pre-existing behavior; this is what the historical
|
|
3010
3097
|
// `latestAssistantText` covered.
|
|
3011
3098
|
//
|
|
3099
|
+
// - source: 'mid-turn'
|
|
3100
|
+
// The leaf IS an assistant message with `stopReason === 'toolUse'` that
|
|
3101
|
+
// carries visible text. The model narrated a user-facing reply ("on it,
|
|
3102
|
+
// bumping to 16x now") AND committed to a tool plan in the same message,
|
|
3103
|
+
// but the turn ended before any follow-up assistant message that would
|
|
3104
|
+
// have called `channel_reply` was persisted — the upstream pi-agent-core
|
|
3105
|
+
// loop's post-tool follow-up never landed, or the run was aborted
|
|
3106
|
+
// mid-loop. The model treated its visible prose as ambient narration; in
|
|
3107
|
+
// a channel session that prose is dead text. Recovers it so the user gets
|
|
3108
|
+
// the reply the model thought it had already given. Observed against
|
|
3109
|
+
// Fireworks' `kimi-k2p6-turbo` on KakaoTalk: the agent posted speed-change
|
|
3110
|
+
// status as narration, kept taking screenshots, and the user saw nothing.
|
|
3111
|
+
// This is the leaf-is-assistant twin of the 'pre-tool' shape below.
|
|
3112
|
+
//
|
|
3012
3113
|
// - source: 'pre-tool'
|
|
3013
3114
|
// The leaf is a `toolResult` and the immediately-prior assistant message
|
|
3014
3115
|
// has `stopReason === 'toolUse'` (it called the tool that produced this
|
|
@@ -3020,22 +3121,34 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
3020
3121
|
//
|
|
3021
3122
|
// Returns null when no recovery is appropriate:
|
|
3022
3123
|
// - No leaf, no messages in branch, branch is malformed
|
|
3023
|
-
// - Leaf is an assistant with
|
|
3124
|
+
// - Leaf is an assistant with `stopReason` of 'length' / 'error' / 'aborted'
|
|
3024
3125
|
// and is NOT preceded by a toolResult pattern — we don't recover partial
|
|
3025
3126
|
// errored output because it's typically a truncation, not a deliberate
|
|
3026
|
-
// reply
|
|
3127
|
+
// reply. Only 'stop' (turn-complete) and 'toolUse' (committed to a tool
|
|
3128
|
+
// plan, prose stranded) signal text the model meant for the user.
|
|
3027
3129
|
// - Leaf is a user/system message (model hasn't responded yet)
|
|
3028
3130
|
//
|
|
3029
3131
|
// `visibleAssistantText` returning '' (empty string) is a valid recovery
|
|
3030
3132
|
// target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
|
|
3031
3133
|
// true) handle the no-content case explicitly via the `no_reply` log.
|
|
3032
|
-
function recoverableAssistantText(
|
|
3134
|
+
function recoverableAssistantText(
|
|
3135
|
+
session: AgentSession,
|
|
3136
|
+
): { text: string; source: 'leaf' | 'mid-turn' | 'pre-tool' } | null {
|
|
3033
3137
|
const leaf = session.sessionManager.getLeafEntry()
|
|
3034
3138
|
if (!leaf) return null
|
|
3035
3139
|
|
|
3036
3140
|
if (leaf.type === 'message' && leaf.message.role === 'assistant') {
|
|
3037
|
-
if (leaf.message.stopReason
|
|
3038
|
-
|
|
3141
|
+
if (leaf.message.stopReason === 'stop') {
|
|
3142
|
+
return { text: visibleAssistantText(leaf.message), source: 'leaf' }
|
|
3143
|
+
}
|
|
3144
|
+
// The model committed to a tool plan but its visible prose never reached
|
|
3145
|
+
// the channel and no follow-up message that would have called a channel
|
|
3146
|
+
// tool was persisted. Recover the stranded prose. Other non-'stop' stop
|
|
3147
|
+
// reasons (length/error/aborted) are truncations, not deliberate replies.
|
|
3148
|
+
if (leaf.message.stopReason === 'toolUse') {
|
|
3149
|
+
return { text: visibleAssistantText(leaf.message), source: 'mid-turn' }
|
|
3150
|
+
}
|
|
3151
|
+
return null
|
|
3039
3152
|
}
|
|
3040
3153
|
|
|
3041
3154
|
// Pre-tool recovery: the leaf must be a toolResult message, and walking
|
package/src/cli/inspect.ts
CHANGED
|
@@ -45,8 +45,12 @@ export const inspectCommand = defineCommand({
|
|
|
45
45
|
|
|
46
46
|
const isJson = args.json === true
|
|
47
47
|
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const
|
|
49
|
-
const
|
|
48
|
+
const signalCtrl = installSigintAbort()
|
|
49
|
+
const signal = signalCtrl.signal
|
|
50
|
+
// Raw-mode Ctrl-C arrives as byte 0x03 and must abort the exit controller
|
|
51
|
+
// directly: under Bun a self-issued process.kill(SIGINT) does not reliably
|
|
52
|
+
// re-enter our process.once('SIGINT') handler, so the live tail never exits.
|
|
53
|
+
const escListener = isJson ? null : createEscListener(() => signalCtrl.abort())
|
|
50
54
|
const liveHint = escListener === null ? undefined : escHintLine(color)
|
|
51
55
|
|
|
52
56
|
// try/finally so a thrown loop never leaves the terminal stuck in raw mode.
|
|
@@ -108,14 +112,14 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
108
112
|
})
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
function installSigintAbort():
|
|
115
|
+
function installSigintAbort(): AbortController {
|
|
112
116
|
const ctrl = new AbortController()
|
|
113
117
|
const onSig = (): void => {
|
|
114
118
|
ctrl.abort()
|
|
115
119
|
}
|
|
116
120
|
process.once('SIGINT', onSig)
|
|
117
121
|
process.once('SIGTERM', onSig)
|
|
118
|
-
return ctrl
|
|
122
|
+
return ctrl
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
type EscListener = {
|
|
@@ -125,8 +129,10 @@ type EscListener = {
|
|
|
125
129
|
stop: () => void
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
133
|
+
|
|
134
|
+
export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
|
|
135
|
+
const stdin = input
|
|
130
136
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
131
137
|
|
|
132
138
|
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
@@ -134,15 +140,17 @@ function createEscListener(): EscListener | null {
|
|
|
134
140
|
|
|
135
141
|
const onData = (chunk: Buffer): void => {
|
|
136
142
|
const { sigint } = ctrl.onChunk(chunk)
|
|
137
|
-
if (sigint)
|
|
143
|
+
if (sigint) onSigint()
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
const start = (): void => {
|
|
141
147
|
if (active) return
|
|
142
148
|
active = true
|
|
143
149
|
stdin.setRawMode(true)
|
|
144
|
-
|
|
150
|
+
// Attach the data handler before resume() so no raw-mode keystroke can slip
|
|
151
|
+
// through between resuming the stream and registering the listener.
|
|
145
152
|
stdin.on('data', onData)
|
|
153
|
+
stdin.resume()
|
|
146
154
|
}
|
|
147
155
|
const stop = (): void => {
|
|
148
156
|
if (!active) return
|
|
@@ -153,7 +161,10 @@ function createEscListener(): EscListener | null {
|
|
|
153
161
|
} catch {
|
|
154
162
|
/* terminal already torn down */
|
|
155
163
|
}
|
|
156
|
-
stdin
|
|
164
|
+
// Do NOT pause stdin here: this teardown hands control to the clack picker,
|
|
165
|
+
// and under Bun clack does not reliably re-flow a previously paused
|
|
166
|
+
// process.stdin, so its keypresses never arrive and arrow keys echo as raw
|
|
167
|
+
// bytes. Leaving the stream flowing lets clack own raw mode during the picker.
|
|
157
168
|
ctrl.clearPending()
|
|
158
169
|
}
|
|
159
170
|
|
package/src/cli/role.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { defineCommand } from 'citty'
|
|
|
3
3
|
|
|
4
4
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
5
5
|
import { findAgentDir } from '@/init'
|
|
6
|
-
import { runClaimSession } from '@/role-claim'
|
|
6
|
+
import { reloadAfterClaim, runClaimSession } from '@/role-claim'
|
|
7
7
|
|
|
8
8
|
import { c, errorLine } from './ui'
|
|
9
9
|
|
|
@@ -76,6 +76,15 @@ const claimSub = defineCommand({
|
|
|
76
76
|
|
|
77
77
|
if (result.kind === 'completed') {
|
|
78
78
|
s.stop(c.green(`Paired as ${result.payload.role}.`))
|
|
79
|
+
s.start('Reloading config so the new match rule takes effect...')
|
|
80
|
+
const reloaded = await reloadAfterClaim({ url })
|
|
81
|
+
if (reloaded.ok) {
|
|
82
|
+
s.stop(c.green('Config reloaded.'))
|
|
83
|
+
} else {
|
|
84
|
+
// The role is already persisted; a reload failure is non-fatal.
|
|
85
|
+
s.stop(c.yellow(`Config reload failed: ${reloaded.reason}`))
|
|
86
|
+
console.log(c.dim('Run `typeclaw reload` manually to apply the new match rule.'))
|
|
87
|
+
}
|
|
79
88
|
outro(`Match rule added: ${c.bold(result.payload.matchRule)}`)
|
|
80
89
|
return
|
|
81
90
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -252,16 +252,18 @@ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = proce
|
|
|
252
252
|
// the exact permission bitfield the adapter uses. No-ops when the token isn't
|
|
253
253
|
// parseable as a Discord bot token so we never block onboarding on best-effort
|
|
254
254
|
// guidance.
|
|
255
|
-
export function printDiscordInviteHint(token: string): void {
|
|
255
|
+
export function printDiscordInviteHint(token: string, output: NodeJS.WritableStream = process.stdout): void {
|
|
256
256
|
const appId = deriveAppIdFromBotToken(token)
|
|
257
257
|
if (appId === null) return
|
|
258
|
+
// URL stays OUT of note(): clack wraps long lines with a `│` gutter that
|
|
259
|
+
// corrupts copy-pasted URLs. Same fix as src/cli/oauth-callbacks.ts.
|
|
258
260
|
note(
|
|
259
261
|
[
|
|
260
|
-
|
|
261
|
-
'',
|
|
262
|
-
'Open it, pick a server, click Authorize.',
|
|
262
|
+
'Open the URL below, pick a server, click Authorize.',
|
|
263
263
|
"The bot won't receive messages until it's in at least one server.",
|
|
264
264
|
].join('\n'),
|
|
265
265
|
'Invite the bot to a server',
|
|
266
266
|
)
|
|
267
|
+
output.write(`${buildDiscordInviteUrl(appId)}\n`)
|
|
268
|
+
output.write('\n')
|
|
267
269
|
}
|
package/src/config/reloadable.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type CreateConfigReloadableOptions = {
|
|
|
11
11
|
// hand-edits) take effect without a container restart. `roles.<name>.permissions`
|
|
12
12
|
// changes still require a restart — see FIELD_EFFECTS in config.ts.
|
|
13
13
|
permissions?: PermissionService
|
|
14
|
+
// Fired after replaceRoles when a `roles.<name>.match` edit is applied. The
|
|
15
|
+
// run stage wires this to the channel router so live sessions are recreated
|
|
16
|
+
// and pick up the new role in their (otherwise frozen) system prompt.
|
|
17
|
+
onRolesChanged?: () => void | Promise<void>
|
|
14
18
|
// Skip the mount-path accessibility check inside validateConfig. Mount paths
|
|
15
19
|
// in typeclaw.json are host paths — they don't resolve inside the container,
|
|
16
20
|
// so the check would always fail on any agent that declares mounts. `mounts`
|
|
@@ -22,18 +26,20 @@ export type CreateConfigReloadableOptions = {
|
|
|
22
26
|
export function createConfigReloadable({
|
|
23
27
|
cwd,
|
|
24
28
|
permissions,
|
|
29
|
+
onRolesChanged,
|
|
25
30
|
skipMountValidation = false,
|
|
26
31
|
}: CreateConfigReloadableOptions): Reloadable {
|
|
27
32
|
return {
|
|
28
33
|
scope: 'config',
|
|
29
34
|
description: 'typeclaw.json runtime config',
|
|
30
|
-
reload: async () => doReload(cwd, permissions, skipMountValidation),
|
|
35
|
+
reload: async () => doReload(cwd, permissions, onRolesChanged, skipMountValidation),
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
async function doReload(
|
|
35
40
|
cwd: string,
|
|
36
41
|
permissions: PermissionService | undefined,
|
|
42
|
+
onRolesChanged: (() => void | Promise<void>) | undefined,
|
|
37
43
|
skipMountValidation: boolean,
|
|
38
44
|
): Promise<ReloadResult> {
|
|
39
45
|
// Mount accessibility belongs to the validation surface, not loadConfigSync —
|
|
@@ -59,8 +65,9 @@ async function doReload(
|
|
|
59
65
|
return { scope: 'config', ok: false, reason: message }
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
if (
|
|
63
|
-
permissions
|
|
68
|
+
if (diff.applied.some((c) => c.path === 'roles.match')) {
|
|
69
|
+
permissions?.replaceRoles(getConfig().roles)
|
|
70
|
+
await onRolesChanged?.()
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
return {
|
package/src/init/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } fr
|
|
|
23
23
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
24
24
|
import { buildHatchingPrompt } from './hatching'
|
|
25
25
|
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
26
|
-
import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
26
|
+
import { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
|
|
27
27
|
import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
28
28
|
|
|
29
29
|
export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
@@ -31,7 +31,7 @@ export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
|
|
|
31
31
|
export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
32
32
|
export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
|
|
33
33
|
|
|
34
|
-
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
34
|
+
export { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
|
|
35
35
|
|
|
36
36
|
export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
|
|
37
37
|
|
|
@@ -39,14 +39,6 @@ const CONFIG_FILE = 'typeclaw.json'
|
|
|
39
39
|
const CRON_FILE = 'cron.json'
|
|
40
40
|
const PACKAGE_FILE = 'package.json'
|
|
41
41
|
|
|
42
|
-
// Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
|
|
43
|
-
// (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
|
|
44
|
-
// matches every channel session on every platform, so the built-in `member`
|
|
45
|
-
// role (which already carries `channel.respond`) covers any inbound the
|
|
46
|
-
// router sees. Without this, freshly-hatched agents silently drop every
|
|
47
|
-
// chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
|
|
48
|
-
const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
|
|
49
|
-
|
|
50
42
|
const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
|
|
51
43
|
|
|
52
44
|
// `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
|
|
@@ -55,7 +47,15 @@ const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as con
|
|
|
55
47
|
// stay in `workspace/`. The directory is scaffolded empty so the layout is
|
|
56
48
|
// discoverable on day one; a `.gitkeep` is written below so it survives the
|
|
57
49
|
// initial commit.
|
|
58
|
-
|
|
50
|
+
//
|
|
51
|
+
// `public/` is a top-level sibling, NOT `workspace/public/`, on purpose:
|
|
52
|
+
// role-based path hiding (src/sandbox/hidden-paths.ts) masks `workspace/` from
|
|
53
|
+
// the guest tier but never masks `public/`, so `public/` is the one place a
|
|
54
|
+
// guest turn can read and write. `workspace/` is an arbitrary free-write zone
|
|
55
|
+
// with no reserved subdir names; a magic `workspace/public/` would silently
|
|
56
|
+
// expose any subdir an agent happened to name `public`. A root sibling keeps
|
|
57
|
+
// the deny-list flat (no carve-out) and the public/private split legible.
|
|
58
|
+
const DIRECTORIES = ['workspace', 'public', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
59
59
|
|
|
60
60
|
export type GitInitResult = { ok: true; skipped: boolean } | { ok: false; reason: string }
|
|
61
61
|
export type DockerAssetsResult = { ok: true; devMode: boolean } | { ok: false; reason: string }
|
|
@@ -552,12 +552,14 @@ export type ScaffoldOptions = {
|
|
|
552
552
|
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
553
553
|
await Promise.all(DIRECTORIES.map((dir) => mkdir(join(root, dir), { recursive: true })))
|
|
554
554
|
|
|
555
|
-
// git does not track empty directories, so without
|
|
556
|
-
// workspace root
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
555
|
+
// git does not track empty directories, so without these files the empty
|
|
556
|
+
// `packages/` (a bun workspace root) and `public/` (the guest-visible zone)
|
|
557
|
+
// would silently disappear from the initial commit. The other DIRECTORIES are
|
|
558
|
+
// either gitignored (workspace, sessions, mounts) or immediately populated.
|
|
559
|
+
await Promise.all([
|
|
560
|
+
writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
|
|
561
|
+
writeFile(join(root, PUBLIC_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
|
|
562
|
+
])
|
|
561
563
|
|
|
562
564
|
// Only fields without sensible defaults elsewhere are emitted. Everything
|
|
563
565
|
// with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
|
|
@@ -576,11 +578,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
576
578
|
if (options.withTelegram) channels['telegram-bot'] = {}
|
|
577
579
|
if (options.withKakaotalk) channels.kakaotalk = {}
|
|
578
580
|
if (Object.keys(channels).length > 0) config.channels = channels
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
581
|
+
// No default `member` match is seeded. A fresh chat agent starts with every
|
|
582
|
+
// inbound author resolving to `guest` (dropped) until the operator claims
|
|
583
|
+
// `owner` (runOwnerClaim, post-hatch) and explicitly grants others. GitHub is
|
|
584
|
+
// wired separately and seeds per-repo `member.match` entries scoped to the
|
|
585
|
+
// opted-in repos. See runOwnerClaim for the mute-until-claimed warning.
|
|
584
586
|
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
585
587
|
|
|
586
588
|
const cron = {
|
|
@@ -1036,8 +1038,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
1036
1038
|
if (options.channel === 'github') {
|
|
1037
1039
|
await appendGithubMatchRules(options.cwd, options.repos)
|
|
1038
1040
|
await maybeInstallGithubWebhooks(options, emit)
|
|
1039
|
-
} else {
|
|
1040
|
-
await ensureDefaultChatMemberMatch(options.cwd)
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
1043
|
// Commit the typeclaw.json change so the agent folder isn't silently
|
|
@@ -1319,24 +1319,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
|
|
|
1319
1319
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
-
// Chat-adapter counterpart of appendGithubMatchRules. See
|
|
1323
|
-
// DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
|
|
1324
|
-
// running `typeclaw channel add` for additional chat adapters is a no-op on
|
|
1325
|
-
// the match list, and any pre-existing rules the operator hand-authored
|
|
1326
|
-
// (e.g. owner-claim's per-author entry on `owner`) are left intact.
|
|
1327
|
-
async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
|
|
1328
|
-
const path = join(cwd, CONFIG_FILE)
|
|
1329
|
-
const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
1330
|
-
const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
|
|
1331
|
-
const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
|
|
1332
|
-
const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
|
|
1333
|
-
if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
|
|
1334
|
-
member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
|
|
1335
|
-
roles.member = member
|
|
1336
|
-
parsed.roles = roles
|
|
1337
|
-
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
1322
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
1341
1323
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
1342
1324
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
package/src/init/paths.ts
CHANGED