typeclaw 0.1.5 → 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 +14 -12
- 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 +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- 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 +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- 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 +385 -12
- package/src/config/index.ts +7 -0
- 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 +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- 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 +50 -33
- 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 +32 -6
- package/src/init/index.ts +183 -62
- 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/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 +55 -6
- 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 +68 -0
- package/src/server/index.ts +122 -11
- 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 +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- 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 +57 -45
|
@@ -22,7 +22,13 @@ export type ChannelOriginContext = {
|
|
|
22
22
|
|
|
23
23
|
export type SessionOrigin =
|
|
24
24
|
| { kind: 'tui'; sessionId: string }
|
|
25
|
-
| {
|
|
25
|
+
| {
|
|
26
|
+
kind: 'cron'
|
|
27
|
+
jobId: string
|
|
28
|
+
jobKind: 'prompt' | 'exec' | 'subagent'
|
|
29
|
+
scheduledByRole?: string
|
|
30
|
+
scheduledByOrigin?: SessionOrigin | { kind: 'config-file' }
|
|
31
|
+
}
|
|
26
32
|
| {
|
|
27
33
|
kind: 'channel'
|
|
28
34
|
adapter: AdapterId
|
|
@@ -35,24 +41,105 @@ export type SessionOrigin =
|
|
|
35
41
|
participants?: readonly ChannelParticipant[]
|
|
36
42
|
membership?: MembershipCount
|
|
37
43
|
}
|
|
38
|
-
| {
|
|
44
|
+
| {
|
|
45
|
+
kind: 'subagent'
|
|
46
|
+
subagent: string
|
|
47
|
+
parentSessionId: string
|
|
48
|
+
spawnedByRole?: string
|
|
49
|
+
spawnedByOrigin?: SessionOrigin
|
|
50
|
+
}
|
|
39
51
|
|
|
40
52
|
export const PARTICIPANTS_TOP_K = 10
|
|
41
53
|
export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
42
54
|
|
|
43
|
-
|
|
55
|
+
// Each adapter renders mentions differently and the model has to copy the
|
|
56
|
+
// exact shape to actually notify a peer. Until this table existed, the
|
|
57
|
+
// channel origin block hardcoded Discord syntax (`<@USER_ID>`) for every
|
|
58
|
+
// non-Slack adapter, which silently misled KakaoTalk and Telegram sessions
|
|
59
|
+
// into emitting addressing tokens that the platform doesn't recognise. The
|
|
60
|
+
// participants block kept rendering `<@authorId> (name)` lines for the
|
|
61
|
+
// same reason — see `renderParticipants`.
|
|
62
|
+
//
|
|
63
|
+
// `mentionMode` semantics:
|
|
64
|
+
// - 'angle-id' — Slack/Discord: `<@USER_ID>` where USER_ID is the
|
|
65
|
+
// raw `authorId` we already surface in participants.
|
|
66
|
+
// - 'at-username' — Telegram: `@username` plain text. The numeric
|
|
67
|
+
// `authorId` is NOT what gets mentioned; usernames are
|
|
68
|
+
// a separate field that not every user has.
|
|
69
|
+
// - 'alias' — KakaoTalk: type the bot's alias as plain text. The
|
|
70
|
+
// adapter's classifier (`kakaotalk-classify.ts`) does
|
|
71
|
+
// a substring match against configured aliases; there
|
|
72
|
+
// is no in-band syntax to copy.
|
|
73
|
+
type PlatformInfo = {
|
|
74
|
+
displayName: string
|
|
75
|
+
mentionMode: 'angle-id' | 'at-username' | 'alias'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
|
|
79
|
+
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
|
|
80
|
+
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
|
|
81
|
+
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
|
|
82
|
+
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
86
|
+
return PLATFORM_INFO[adapter]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compact description of the role the runtime resolved for this session at
|
|
90
|
+
// creation time. Rendered as a single block under the origin text for
|
|
91
|
+
// non-TUI sessions so the agent knows what it can and cannot do without
|
|
92
|
+
// having to call into the PermissionService itself. TUI is omitted because
|
|
93
|
+
// TUI is always `owner` by construction — annotating it would add noise to
|
|
94
|
+
// every interactive session for zero new information.
|
|
95
|
+
//
|
|
96
|
+
// For channel sessions this is a session-creation snapshot. The router
|
|
97
|
+
// re-resolves per-turn for tool gating, but the system prompt is not
|
|
98
|
+
// regenerated mid-session; the role line is accurate at admission and the
|
|
99
|
+
// `typeclaw-permissions` skill spells out how to interpret it on later
|
|
100
|
+
// turns when a different speaker may have spoken last.
|
|
101
|
+
export type SessionRoleContext = {
|
|
102
|
+
role: string
|
|
103
|
+
permissions: readonly string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function renderSessionOrigin(
|
|
107
|
+
origin: SessionOrigin,
|
|
108
|
+
now: number = Date.now(),
|
|
109
|
+
roleContext?: SessionRoleContext,
|
|
110
|
+
): string {
|
|
44
111
|
switch (origin.kind) {
|
|
45
112
|
case 'tui':
|
|
46
|
-
return renderTuiOrigin()
|
|
113
|
+
return withRoleContext(renderTuiOrigin(), roleContext)
|
|
47
114
|
case 'cron':
|
|
48
|
-
return renderCronOrigin(origin)
|
|
115
|
+
return withRoleContext(renderCronOrigin(origin), roleContext)
|
|
49
116
|
case 'channel':
|
|
50
|
-
return renderChannelOrigin(origin, now)
|
|
117
|
+
return withRoleContext(renderChannelOrigin(origin, now), roleContext)
|
|
51
118
|
case 'subagent':
|
|
52
|
-
return renderSubagentOrigin(origin)
|
|
119
|
+
return withRoleContext(renderSubagentOrigin(origin), roleContext)
|
|
53
120
|
}
|
|
54
121
|
}
|
|
55
122
|
|
|
123
|
+
function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
|
|
124
|
+
if (ctx === undefined) return block
|
|
125
|
+
return `${block}\n\n${renderRoleContext(ctx)}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderRoleContext(ctx: SessionRoleContext): string {
|
|
129
|
+
const permList = ctx.permissions.length === 0 ? 'none' : ctx.permissions.map((p) => `\`${p}\``).join(', ')
|
|
130
|
+
return [
|
|
131
|
+
'## Your role in this session',
|
|
132
|
+
'',
|
|
133
|
+
`Role: \`${ctx.role}\`. Permissions: ${permList}.`,
|
|
134
|
+
'',
|
|
135
|
+
'This is the role the runtime resolved at session creation. Tool calls',
|
|
136
|
+
'and channel admission are gated by these permissions; a `blocked:` or',
|
|
137
|
+
'"denied by permissions" message means the current actor lacks the',
|
|
138
|
+
'permission the guard was looking for. See the `typeclaw-permissions`',
|
|
139
|
+
'skill for what each role can do and how to grant access.',
|
|
140
|
+
].join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
56
143
|
function renderTuiOrigin(): string {
|
|
57
144
|
return [
|
|
58
145
|
'## Session origin',
|
|
@@ -118,11 +205,11 @@ function renderChannelOrigin(
|
|
|
118
205
|
// only `text` and pulls addressing from this origin. We point the model at
|
|
119
206
|
// it as the default, and keep channel_send as the escape hatch for posting
|
|
120
207
|
// elsewhere (different chat, breaking out of the thread on purpose, etc.).
|
|
121
|
-
const
|
|
208
|
+
const platformInfo = getPlatformInfo(origin.adapter)
|
|
122
209
|
const lines: string[] = [
|
|
123
210
|
'## Session origin',
|
|
124
211
|
'',
|
|
125
|
-
`You are responding inside a ${
|
|
212
|
+
`You are responding inside a ${platformInfo.displayName} channel session. There is no human`,
|
|
126
213
|
'attached to a console here — your only way to communicate with the user',
|
|
127
214
|
'is a tool call. Plain-text output is invisible.',
|
|
128
215
|
]
|
|
@@ -157,11 +244,10 @@ function renderChannelOrigin(
|
|
|
157
244
|
"matching the channel's `allow` rules are accepted (the tool returns",
|
|
158
245
|
'`{ ok: false }` otherwise).',
|
|
159
246
|
'',
|
|
160
|
-
|
|
161
|
-
...renderMentionExample(origin.participants ?? [], platform, now),
|
|
247
|
+
...renderMentionGuidance(platformInfo, origin.participants ?? [], now),
|
|
162
248
|
)
|
|
163
249
|
|
|
164
|
-
const participantsBlock = renderParticipants(origin.participants ?? [], now)
|
|
250
|
+
const participantsBlock = renderParticipants(origin.participants ?? [], platformInfo, now)
|
|
165
251
|
const membershipLine = renderMembershipSummary(origin, now)
|
|
166
252
|
if (membershipLine !== null) lines.push('', membershipLine)
|
|
167
253
|
if (participantsBlock) lines.push('', participantsBlock)
|
|
@@ -189,23 +275,11 @@ function renderMembershipSummary(
|
|
|
189
275
|
return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap).${caveat} The 10 most recent speakers are listed below.`
|
|
190
276
|
}
|
|
191
277
|
|
|
192
|
-
function
|
|
278
|
+
function renderMentionGuidance(
|
|
279
|
+
platformInfo: PlatformInfo,
|
|
193
280
|
participants: readonly ChannelParticipant[],
|
|
194
|
-
platform: 'Discord' | 'Slack',
|
|
195
281
|
now: number,
|
|
196
282
|
): string[] {
|
|
197
|
-
// Concrete worked example anchored on a REAL participant when possible.
|
|
198
|
-
// Models reliably copy concrete examples; abstract `<@USER_ID>` placeholders
|
|
199
|
-
// get treated as generic instructions and ignored. Prefer a peer bot for
|
|
200
|
-
// the example because that's the addressing case where plain-text names
|
|
201
|
-
// silently fail (the human path is forgiving — humans see their name and
|
|
202
|
-
// respond regardless of mention syntax). Fall back to any non-self
|
|
203
|
-
// participant, then to a generic placeholder if the channel is brand new.
|
|
204
|
-
//
|
|
205
|
-
// Apply the SAME staleness cutoff as `renderParticipants` so we never name
|
|
206
|
-
// someone in the example who isn't shown in the participants block — that
|
|
207
|
-
// would surface a "ghost" name from >7d ago and confuse the model about
|
|
208
|
-
// who is actually around.
|
|
209
283
|
const cutoff = now - PARTICIPANTS_MAX_AGE_MS
|
|
210
284
|
const fresh = [...participants]
|
|
211
285
|
.filter((p) => p.lastMessageAt >= cutoff)
|
|
@@ -214,11 +288,32 @@ function renderMentionExample(
|
|
|
214
288
|
const anyPeer = peerBot ?? fresh[0]
|
|
215
289
|
const exampleId = anyPeer?.authorId ?? '123456789'
|
|
216
290
|
const exampleName = anyPeer?.authorName ?? 'PeerBot'
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
291
|
+
|
|
292
|
+
switch (platformInfo.mentionMode) {
|
|
293
|
+
case 'angle-id':
|
|
294
|
+
return [
|
|
295
|
+
`To mention someone in your reply, use ${platformInfo.displayName} syntax \`<@USER_ID>\`.`,
|
|
296
|
+
`For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
|
|
297
|
+
`**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platformInfo.displayName},`,
|
|
298
|
+
'and other bots in this channel will not see the message as addressed to them.',
|
|
299
|
+
]
|
|
300
|
+
case 'at-username':
|
|
301
|
+
return [
|
|
302
|
+
`To mention someone in your reply, use Telegram syntax \`@username\` in plain text.`,
|
|
303
|
+
`Telegram usernames are a SEPARATE field from \`authorId\`. The \`<@id>\` tokens you see in the participants`,
|
|
304
|
+
'block below are a typeclaw convention for parsing inbound mentions — do not echo them back as outbound mentions.',
|
|
305
|
+
'If you only know an author by their display name and they have no `@username`, address them by display name',
|
|
306
|
+
'and they will see the message via the reply context.',
|
|
307
|
+
]
|
|
308
|
+
case 'alias':
|
|
309
|
+
return [
|
|
310
|
+
'KakaoTalk has no in-band mention syntax. To address someone, just type their display name as plain text;',
|
|
311
|
+
"the participants block below shows display names. To get the BOT's attention from outside this session,",
|
|
312
|
+
"a user types one of the bot's configured aliases — they do not need to copy any token from the participants list.",
|
|
313
|
+
`The \`<@id>\` tokens in the participants block below are a typeclaw convention for parsing inbound mentions —`,
|
|
314
|
+
'do not echo them back as outbound mentions; KakaoTalk would render them as literal text.',
|
|
315
|
+
]
|
|
316
|
+
}
|
|
222
317
|
}
|
|
223
318
|
|
|
224
319
|
function renderConversationLine(origin: {
|
|
@@ -239,26 +334,22 @@ function renderConversationLine(origin: {
|
|
|
239
334
|
return `Conversation: ${chatLabel} in ${workspaceLabel}.`
|
|
240
335
|
}
|
|
241
336
|
|
|
242
|
-
function renderParticipants(
|
|
337
|
+
function renderParticipants(
|
|
338
|
+
participants: readonly ChannelParticipant[],
|
|
339
|
+
platformInfo: PlatformInfo,
|
|
340
|
+
now: number,
|
|
341
|
+
): string {
|
|
243
342
|
const cutoff = now - PARTICIPANTS_MAX_AGE_MS
|
|
244
343
|
const fresh = participants.filter((p) => p.lastMessageAt >= cutoff)
|
|
245
344
|
if (fresh.length === 0) return ''
|
|
246
345
|
|
|
247
346
|
const top = [...fresh].sort((a, b) => b.lastMessageAt - a.lastMessageAt).slice(0, PARTICIPANTS_TOP_K)
|
|
248
347
|
|
|
249
|
-
// Format flipped from `name (id: 123)` to `<@123> (name)` so the model sees
|
|
250
|
-
// the SAME shape it will need to emit when addressing someone — copy-paste
|
|
251
|
-
// the leading `<@id>` token verbatim. The previous format presented the
|
|
252
|
-
// human-readable name first and the ID parenthetically, which (combined
|
|
253
|
-
// with `<@id> (name) [bot]:` in inbound message lines) trained the model
|
|
254
|
-
// to treat `<@id>` as Discord's render-time decoration rather than syntax
|
|
255
|
-
// it must produce. Symptom in the wild: 돌쇠 addressing Winky as "Winky님"
|
|
256
|
-
// (plain text), which never trips Winky's `isBotMention` check, so Winky
|
|
257
|
-
// observes silently and the conversation stalls.
|
|
258
348
|
const lines = ['## Recent participants (last 7 days, top 10 by recency)', '']
|
|
259
349
|
for (const p of top) {
|
|
260
350
|
const ago = formatAgo(now - p.lastMessageAt)
|
|
261
|
-
|
|
351
|
+
const addressing = renderParticipantAddressing(p, platformInfo)
|
|
352
|
+
lines.push(`- ${addressing} — last message: ${ago}, total: ${p.messageCount}`)
|
|
262
353
|
}
|
|
263
354
|
lines.push(
|
|
264
355
|
'',
|
|
@@ -268,14 +359,71 @@ function renderParticipants(participants: readonly ChannelParticipant[], now: nu
|
|
|
268
359
|
'This is **not** the full guild member list, and **not** an audit log',
|
|
269
360
|
'of everyone who ever spoke here.',
|
|
270
361
|
'',
|
|
271
|
-
|
|
272
|
-
'address them — `<@authorId>` works for any author you have seen,',
|
|
273
|
-
'even once. The list is a convenience for "who\'s been around lately,"',
|
|
274
|
-
'not an exhaustive directory.',
|
|
362
|
+
...renderParticipantsTrailing(platformInfo),
|
|
275
363
|
)
|
|
276
364
|
return lines.join('\n')
|
|
277
365
|
}
|
|
278
366
|
|
|
367
|
+
// Per-line addressing token shown for each participant. The shape must match
|
|
368
|
+
// what the model will need to emit when addressing that participant, so the
|
|
369
|
+
// model can copy-paste the leading token verbatim. The previous unconditional
|
|
370
|
+
// `<@id> (name)` format trained the model toward angle-id syntax on every
|
|
371
|
+
// platform — correct for Discord/Slack, wrong for KakaoTalk (no in-band
|
|
372
|
+
// mention syntax) and Telegram (uses `@username`, where `authorId` is a
|
|
373
|
+
// numeric id and NOT the username). See issue #188.
|
|
374
|
+
//
|
|
375
|
+
// Symptom in the wild before PR #183 + this fix: 돌쇠 addressing Winky as
|
|
376
|
+
// "Winky님" (plain text) on Discord, which never trips Winky's `isBotMention`
|
|
377
|
+
// check, so Winky observes silently and the conversation stalls. The
|
|
378
|
+
// angle-id branch here is exactly the fix for that case; the at-username
|
|
379
|
+
// and alias branches keep the platform contract honest for KakaoTalk and
|
|
380
|
+
// Telegram instead of self-contradicting the per-adapter mention guidance
|
|
381
|
+
// produced by `renderMentionGuidance`.
|
|
382
|
+
function renderParticipantAddressing(p: ChannelParticipant, platformInfo: PlatformInfo): string {
|
|
383
|
+
switch (platformInfo.mentionMode) {
|
|
384
|
+
case 'angle-id':
|
|
385
|
+
return `<@${p.authorId}> (${p.authorName})`
|
|
386
|
+
case 'at-username':
|
|
387
|
+
case 'alias':
|
|
388
|
+
return `${p.authorName} (${p.authorId})`
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Closing prose for the participants block. Mirrors the per-platform branch
|
|
393
|
+
// in `renderParticipantAddressing` so the trailing "address them" guidance
|
|
394
|
+
// matches the format the bullet points just demonstrated. The previous
|
|
395
|
+
// unconditional `<@authorId>` prose was the second voice in the
|
|
396
|
+
// self-contradiction noted in issue #188 — it told KakaoTalk/Telegram
|
|
397
|
+
// sessions to address peers with a syntax `renderMentionGuidance` had
|
|
398
|
+
// just told them not to use.
|
|
399
|
+
function renderParticipantsTrailing(platformInfo: PlatformInfo): string[] {
|
|
400
|
+
switch (platformInfo.mentionMode) {
|
|
401
|
+
case 'angle-id':
|
|
402
|
+
return [
|
|
403
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
404
|
+
'address them — `<@authorId>` works for any author you have seen,',
|
|
405
|
+
'even once. The list is a convenience for "who\'s been around lately,"',
|
|
406
|
+
'not an exhaustive directory.',
|
|
407
|
+
]
|
|
408
|
+
case 'at-username':
|
|
409
|
+
return [
|
|
410
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
411
|
+
'address them by `@username` — Telegram usernames are a SEPARATE field',
|
|
412
|
+
'from the numeric `authorId` shown in parentheses above, and not every',
|
|
413
|
+
'user has one. The list is a convenience for "who\'s been around',
|
|
414
|
+
'lately," not an exhaustive directory.',
|
|
415
|
+
]
|
|
416
|
+
case 'alias':
|
|
417
|
+
return [
|
|
418
|
+
"If a sender in the current turn isn't in the list, you can still",
|
|
419
|
+
'address them by display name as plain text — KakaoTalk has no in-band',
|
|
420
|
+
'mention syntax, so the `authorId` shown in parentheses above is for',
|
|
421
|
+
'your reference only and must not be echoed back. The list is a',
|
|
422
|
+
'convenience for "who\'s been around lately," not an exhaustive directory.',
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
279
427
|
function formatAgo(ms: number): string {
|
|
280
428
|
const sec = Math.max(0, Math.round(ms / 1000))
|
|
281
429
|
if (sec < 60) return `${sec} seconds ago`
|
package/src/agent/subagents.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Stream, Unsubscribe } from '@/stream'
|
|
|
6
6
|
|
|
7
7
|
import { type AgentSession, createSession } from './index'
|
|
8
8
|
import type { SessionOrigin } from './session-origin'
|
|
9
|
+
import type { ToolResultBudget } from './tool-result-budget'
|
|
9
10
|
|
|
10
11
|
type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
|
|
11
12
|
|
|
@@ -19,10 +20,15 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
|
|
|
19
20
|
|
|
20
21
|
export type Subagent<P = unknown> = {
|
|
21
22
|
systemPrompt: string
|
|
23
|
+
// Model profile this subagent prefers. Resolved against `config.models` at
|
|
24
|
+
// session construction. Unknown profile names fall back to `default` with
|
|
25
|
+
// a warning. See `Subagent` in `@/plugin/types` for the full contract.
|
|
26
|
+
profile?: string
|
|
22
27
|
tools?: AgentSessionTools
|
|
23
28
|
customTools?: ToolDefinition[]
|
|
24
29
|
payloadSchema?: z.ZodType<P>
|
|
25
30
|
handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
|
|
31
|
+
toolResultBudget?: ToolResultBudget
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export type SubagentRegistry = Readonly<Record<string, Subagent<any>>>
|
|
@@ -62,6 +68,8 @@ export type CreateSessionForSubagentResult = {
|
|
|
62
68
|
export type CreateSessionForSubagentOptions = {
|
|
63
69
|
name?: string
|
|
64
70
|
parentSessionId?: string
|
|
71
|
+
spawnedByRole?: string
|
|
72
|
+
spawnedByOrigin?: SessionOrigin
|
|
65
73
|
}
|
|
66
74
|
export type CreateSessionForSubagent = (
|
|
67
75
|
subagent: Subagent<any>,
|
|
@@ -75,9 +83,13 @@ export const defaultCreateSessionForSubagent: CreateSessionForSubagent = (subage
|
|
|
75
83
|
kind: 'subagent',
|
|
76
84
|
subagent: options?.name ?? '<unknown>',
|
|
77
85
|
parentSessionId: options?.parentSessionId ?? '<unknown>',
|
|
86
|
+
...(options?.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
|
|
87
|
+
...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
78
88
|
},
|
|
79
89
|
...(subagent.tools ? { tools: subagent.tools } : {}),
|
|
80
90
|
customTools: subagent.customTools ?? [],
|
|
91
|
+
...(subagent.profile !== undefined ? { profile: subagent.profile } : {}),
|
|
92
|
+
...(subagent.toolResultBudget !== undefined ? { toolResultBudget: subagent.toolResultBudget } : {}),
|
|
81
93
|
})
|
|
82
94
|
|
|
83
95
|
type NormalizedSubagentSession = {
|
|
@@ -120,6 +132,8 @@ export type InvokeSubagentOptions = {
|
|
|
120
132
|
userPrompt: string
|
|
121
133
|
payload?: unknown
|
|
122
134
|
parentSessionId?: string
|
|
135
|
+
spawnedByRole?: string
|
|
136
|
+
spawnedByOrigin?: SessionOrigin
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
|
|
@@ -131,6 +145,8 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
131
145
|
const sessionOptions: CreateSessionForSubagentOptions = {
|
|
132
146
|
name,
|
|
133
147
|
...(options.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
|
|
148
|
+
...(options.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
|
|
149
|
+
...(options.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
const runSession: RunSession = async (override) => {
|
|
@@ -157,11 +173,12 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
157
173
|
sessionId,
|
|
158
174
|
parentTranscriptPath: getTranscriptPath?.(),
|
|
159
175
|
idleMs: 0,
|
|
176
|
+
...(origin !== undefined ? { origin } : {}),
|
|
160
177
|
})
|
|
161
178
|
}
|
|
162
179
|
} finally {
|
|
163
180
|
if (hooks && sessionId !== undefined) {
|
|
164
|
-
await hooks.runSessionEnd({ sessionId })
|
|
181
|
+
await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
|
|
165
182
|
}
|
|
166
183
|
session.dispose()
|
|
167
184
|
await dispose()
|
|
@@ -215,6 +232,40 @@ const consoleLogger: SubagentConsumerLogger = {
|
|
|
215
232
|
error: (m) => console.error(m),
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
function parseSpawnedByOriginJson(
|
|
236
|
+
raw: string | undefined,
|
|
237
|
+
logger: SubagentConsumerLogger,
|
|
238
|
+
subagentName: string,
|
|
239
|
+
): SessionOrigin | undefined {
|
|
240
|
+
if (raw === undefined) return undefined
|
|
241
|
+
let parsed: unknown
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(raw)
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
246
|
+
logger.warn(`[subagent] ${subagentName}: ignoring malformed spawnedByOriginJson on stream target: ${message}`)
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
// Shape-validate the decoded value so a malformed sender (or a future
|
|
250
|
+
// bug in cron consumer's encode side) cannot poison the subagent's
|
|
251
|
+
// origin with arbitrary shapes. The check is narrow: object with a
|
|
252
|
+
// `kind` field whose value is one of the SessionOrigin discriminator
|
|
253
|
+
// strings. Permission resolution treats unknown shapes as guest, so
|
|
254
|
+
// failing closed here matches the rest of the system.
|
|
255
|
+
if (!isSessionOriginShape(parsed)) {
|
|
256
|
+
logger.warn(`[subagent] ${subagentName}: ignoring spawnedByOriginJson with unrecognized SessionOrigin shape`)
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
return parsed
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const SESSION_ORIGIN_KINDS = new Set(['tui', 'cron', 'channel', 'subagent'])
|
|
263
|
+
function isSessionOriginShape(value: unknown): value is SessionOrigin {
|
|
264
|
+
if (value === null || typeof value !== 'object') return false
|
|
265
|
+
const kind = (value as { kind?: unknown }).kind
|
|
266
|
+
return typeof kind === 'string' && SESSION_ORIGIN_KINDS.has(kind)
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
export function createSubagentConsumer({
|
|
219
270
|
stream,
|
|
220
271
|
getRegistry,
|
|
@@ -234,6 +285,8 @@ export function createSubagentConsumer({
|
|
|
234
285
|
kind: 'new-session'
|
|
235
286
|
subagent: string
|
|
236
287
|
parentSessionId?: string
|
|
288
|
+
spawnedByRole?: string
|
|
289
|
+
spawnedByOriginJson?: string
|
|
237
290
|
}
|
|
238
291
|
const name = target.subagent
|
|
239
292
|
const registry = getRegistry()
|
|
@@ -248,6 +301,7 @@ export function createSubagentConsumer({
|
|
|
248
301
|
}
|
|
249
302
|
inFlight.add(key)
|
|
250
303
|
try {
|
|
304
|
+
const spawnedByOrigin = parseSpawnedByOriginJson(target.spawnedByOriginJson, logger, name)
|
|
251
305
|
await invokeSubagent(name, {
|
|
252
306
|
registry,
|
|
253
307
|
...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
|
|
@@ -255,6 +309,8 @@ export function createSubagentConsumer({
|
|
|
255
309
|
userPrompt: '',
|
|
256
310
|
payload: msg.payload,
|
|
257
311
|
...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
|
|
312
|
+
...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
|
|
313
|
+
...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
|
|
258
314
|
})
|
|
259
315
|
} catch (err) {
|
|
260
316
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -20,7 +20,7 @@ These files are not decoration. They shape how you behave. If a task reveals som
|
|
|
20
20
|
|
|
21
21
|
- **\`workspace/\`** — the directory where you are free to create files: drafts, notes, downloads, scratch work, generated artifacts, temporary outputs. **Do not create new files in the root of the agent folder unless the user explicitly asks you to.** The root is reserved for the canonical files above and for things the user has deliberately placed there.
|
|
22
22
|
- **\`sessions/\`** — transcripts of past conversations (\`<sessionid>.jsonl\`). Read-only for you in spirit; the runtime manages these.
|
|
23
|
-
- **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.
|
|
23
|
+
- **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.jsonl\`) of fragments captured by the memory-logger between sessions. Newest day is closest to the current task. Once dreaming consolidates a day's stream into MEMORY.md, the runtime stops injecting it.
|
|
24
24
|
- **\`memory/skills/\`** — *muscle memory*. Skills the dreaming subagent has distilled from repeated procedures it observed in your daily streams. Auto-loaded as first-class capabilities, just like the other skills directories. **You do not write here directly** — dreaming owns it. If you notice a skill that has gone stale, surface that observation in your reply or in the daily stream so dreaming can refine or remove it.
|
|
25
25
|
- **\`.agents/skills/\`** — skills the user installed for you. Treat these as first-class capabilities.
|
|
26
26
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AgentTool } from '@mariozechner/pi-agent-core'
|
|
2
|
+
import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
4
|
+
|
|
5
|
+
// Subagents that read large files (memory-logger and dreaming each read parent
|
|
6
|
+
// session transcripts that can run hundreds of KB) are vulnerable to a class
|
|
7
|
+
// of bug where a single tool malfunction — a broken `find_entry`, a missing
|
|
8
|
+
// watermark, a transcript that no longer contains the watermark id — causes
|
|
9
|
+
// the agent to fall back to scanning the file in 50KB chunks. Every chunk
|
|
10
|
+
// stays in the subagent's conversation history and gets re-sent to the model
|
|
11
|
+
// on every turn until the subagent stops, so a 1MB transcript can balloon a
|
|
12
|
+
// memory-logger run from ~10K input tokens to several hundred thousand.
|
|
13
|
+
//
|
|
14
|
+
// The budget here is a fail-safe ceiling on the total bytes of tool-result
|
|
15
|
+
// text a subagent run is allowed to accumulate from a chosen set of tools.
|
|
16
|
+
// Once exhausted, subsequent calls to those tools short-circuit with a
|
|
17
|
+
// constant-size message that tells the agent to advance the watermark to the
|
|
18
|
+
// latest entry and exit. The budget is per-run (one BudgetState per session)
|
|
19
|
+
// and tracked only for the named tools; tools like `append` (which write,
|
|
20
|
+
// not read) are unaffected.
|
|
21
|
+
|
|
22
|
+
export type ToolResultBudget = {
|
|
23
|
+
maxTotalBytes: number
|
|
24
|
+
toolNames: readonly string[]
|
|
25
|
+
exhaustedMessage?: (used: number, max: number) => string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BudgetState = {
|
|
29
|
+
used: number
|
|
30
|
+
exhausted: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createBudgetState(): BudgetState {
|
|
34
|
+
return { used: 0, exhausted: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultExhaustedMessage(used: number, max: number): string {
|
|
38
|
+
const usedKb = Math.round(used / 1024)
|
|
39
|
+
const maxKb = Math.round(max / 1024)
|
|
40
|
+
return [
|
|
41
|
+
`[tool-result budget exhausted: used ${usedKb}KB of ${maxKb}KB this run]`,
|
|
42
|
+
'',
|
|
43
|
+
'Stop reading. This session has consumed its byte budget across calls to',
|
|
44
|
+
'this tool. Do not call this tool again. Stop and exit; future runs will',
|
|
45
|
+
'continue from wherever your normal end-of-run bookkeeping left off.',
|
|
46
|
+
].join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bytesOfContent(content: { type: string; text?: string }[] | undefined): number {
|
|
50
|
+
if (!content) return 0
|
|
51
|
+
let total = 0
|
|
52
|
+
for (const part of content) {
|
|
53
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
54
|
+
total += Buffer.byteLength(part.text, 'utf8')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return total
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildExhaustedResult(budget: ToolResultBudget, state: BudgetState) {
|
|
61
|
+
const text = (budget.exhaustedMessage ?? defaultExhaustedMessage)(state.used, budget.maxTotalBytes)
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text' as const, text }],
|
|
64
|
+
details: { budgetExhausted: true, used: state.used, max: budget.maxTotalBytes },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wraps an AgentTool's execute so that returned text content is counted against
|
|
69
|
+
// `state` and the tool short-circuits once `budget.maxTotalBytes` is exceeded.
|
|
70
|
+
// Tools whose name is not in `budget.toolNames` are returned unchanged so the
|
|
71
|
+
// caller can pass an entire `tools` array through and only the tracked tools
|
|
72
|
+
// are affected. The original tool object is preserved by spreading; only
|
|
73
|
+
// `execute` is replaced.
|
|
74
|
+
export function wrapAgentToolWithBudget<TParams extends TSchema, TDetails = unknown>(
|
|
75
|
+
tool: AgentTool<TParams, TDetails>,
|
|
76
|
+
budget: ToolResultBudget,
|
|
77
|
+
state: BudgetState,
|
|
78
|
+
): AgentTool<TParams, TDetails> {
|
|
79
|
+
if (!budget.toolNames.includes(tool.name)) return tool
|
|
80
|
+
const originalExecute = tool.execute.bind(tool)
|
|
81
|
+
return {
|
|
82
|
+
...tool,
|
|
83
|
+
async execute(toolCallId, args, signal, onUpdate) {
|
|
84
|
+
if (state.exhausted) {
|
|
85
|
+
return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
|
|
86
|
+
}
|
|
87
|
+
const result = await originalExecute(toolCallId, args, signal, onUpdate)
|
|
88
|
+
state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
|
|
89
|
+
if (state.used >= budget.maxTotalBytes) {
|
|
90
|
+
state.exhausted = true
|
|
91
|
+
}
|
|
92
|
+
return result
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Same wrapper for ToolDefinition (the customTools surface). Identical
|
|
98
|
+
// semantics; ToolDefinition's execute has an extra `onUpdate` callback and a
|
|
99
|
+
// `ctx` argument that we forward verbatim.
|
|
100
|
+
export function wrapToolDefinitionWithBudget<TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
101
|
+
tool: ToolDefinition<TParams, TDetails, TState>,
|
|
102
|
+
budget: ToolResultBudget,
|
|
103
|
+
state: BudgetState,
|
|
104
|
+
): ToolDefinition<TParams, TDetails, TState> {
|
|
105
|
+
if (!budget.toolNames.includes(tool.name)) return tool
|
|
106
|
+
const originalExecute = tool.execute.bind(tool)
|
|
107
|
+
return {
|
|
108
|
+
...tool,
|
|
109
|
+
async execute(toolCallId, args, signal, onUpdate, ctx) {
|
|
110
|
+
if (state.exhausted) {
|
|
111
|
+
return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
|
|
112
|
+
}
|
|
113
|
+
const result = await originalExecute(toolCallId, args, signal, onUpdate, ctx)
|
|
114
|
+
state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
|
|
115
|
+
if (state.used >= budget.maxTotalBytes) {
|
|
116
|
+
state.exhausted = true
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import { definePlugin, type Subagent } from '@/plugin'
|
|
3
|
+
import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
|
|
4
4
|
|
|
5
5
|
import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
|
|
6
6
|
import {
|
|
@@ -78,10 +78,19 @@ export default definePlugin({
|
|
|
78
78
|
if (activeTurns.size > 0) return
|
|
79
79
|
inFlight = true
|
|
80
80
|
try {
|
|
81
|
-
await ctx.spawnSubagent(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
await ctx.spawnSubagent(
|
|
82
|
+
SUBAGENT_BACKUP_RUNNER,
|
|
83
|
+
{
|
|
84
|
+
agentDir: ctx.agentDir,
|
|
85
|
+
pushToOrigin,
|
|
86
|
+
} satisfies RunnerPayload,
|
|
87
|
+
// The backup runner is a system-level operation that commits +
|
|
88
|
+
// pushes on the operator's behalf. It runs after every idle
|
|
89
|
+
// window regardless of which session caused activity, so it has
|
|
90
|
+
// no single user session to inherit from. Mark it as TUI-equivalent
|
|
91
|
+
// so it resolves to `owner` and can use git push, etc.
|
|
92
|
+
{ spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' } },
|
|
93
|
+
)
|
|
85
94
|
} catch (err) {
|
|
86
95
|
ctx.logger.error(`backup runner spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
87
96
|
} finally {
|
|
@@ -152,12 +161,18 @@ async function runBackupOnce(
|
|
|
152
161
|
ctx: {
|
|
153
162
|
agentDir: string
|
|
154
163
|
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
155
|
-
spawnSubagent:
|
|
164
|
+
spawnSubagent: PluginContext['spawnSubagent']
|
|
156
165
|
},
|
|
157
166
|
): Promise<BackupResult> {
|
|
158
167
|
const messagePath = messageFilePath(payload.agentDir)
|
|
159
168
|
await ensureMessageDir(messagePath)
|
|
160
169
|
await cleanupMessageFile(messagePath)
|
|
170
|
+
// Inherit the backup-runner's owner privileges for the message-picking
|
|
171
|
+
// and diagnose subagents it spawns. Same rationale as the runner itself
|
|
172
|
+
// — these are system-level operations on the operator's behalf.
|
|
173
|
+
const inheritOwner: SpawnSubagentOptions = {
|
|
174
|
+
spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
|
|
175
|
+
}
|
|
161
176
|
|
|
162
177
|
const result = await runBackup(
|
|
163
178
|
{ cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
|
|
@@ -172,7 +187,7 @@ async function runBackupOnce(
|
|
|
172
187
|
outputPath: messagePath,
|
|
173
188
|
}
|
|
174
189
|
try {
|
|
175
|
-
await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload)
|
|
190
|
+
await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload, inheritOwner)
|
|
176
191
|
} catch (err) {
|
|
177
192
|
ctx.logger.warn(
|
|
178
193
|
`${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -191,7 +206,7 @@ async function runBackupOnce(
|
|
|
191
206
|
stdout: input.stdout,
|
|
192
207
|
}
|
|
193
208
|
try {
|
|
194
|
-
await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload)
|
|
209
|
+
await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload, inheritOwner)
|
|
195
210
|
} catch (err) {
|
|
196
211
|
ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
197
212
|
}
|