@swarmclawai/swarmclaw 0.9.4 → 0.9.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.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. package/src/views/settings/section-wallets.tsx +35 -0
package/README.md CHANGED
@@ -155,7 +155,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
155
155
  The installer resolves the latest stable release tag and installs that version by default.
156
156
  It also builds the production bundle so `npm run start` is ready immediately after install.
157
157
  No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
158
- To pin a version: `SWARMCLAW_VERSION=v0.9.4 curl ... | bash`
158
+ To pin a version: `SWARMCLAW_VERSION=v0.9.6 curl ... | bash`
159
159
 
160
160
  Or run locally from the repo (friendly for non-technical users):
161
161
 
@@ -729,15 +729,15 @@ On `v*` tags, GitHub Actions will:
729
729
  2. Create a GitHub Release
730
730
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
731
731
 
732
- #### v0.9.4 Release Readiness Notes
732
+ #### v0.9.6 Release Readiness Notes
733
733
 
734
- Before shipping `v0.9.4`, confirm the following user-facing changes are reflected in docs:
734
+ Before shipping `v0.9.6`, confirm the following user-facing changes are reflected in docs:
735
735
 
736
- 1. Skills docs explain that local skills are discoverable by default, while `skillIds` now mean pinned always-on skills for an agent.
737
- 2. Runtime-skill docs mention executable skill metadata, on-demand selection, and the `use_skill` / `manage_skills` flow instead of implying every discovered skill is inlined into the prompt.
738
- 3. Connector/heartbeat docs mention that routable connector state is kept on direct connector sessions only, sender quiet-boundary memories are enforced before reply generation, and tool-only heartbeats no longer pollute visible main-thread history.
739
- 4. Site and README install/version strings are updated to `v0.9.4`, including pinned install snippets, release notes index text, and sidebar/footer labels.
740
- 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.4`) versus the git tag (`v0.9.4`).
736
+ 1. Wallet docs explain the new global wallet approval override in Settings/Wallets, and note that per-wallet approval toggles remain stored but are ignored when the global switch is off.
737
+ 2. Runtime/autonomy docs mention the continuation hardening for long-running tasks: intent-only kickoff replies now get one bounded followthrough, and chat-originated progress runs can schedule a bounded main-loop continuation without waiting for another user ping.
738
+ 3. Skills/runtime docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
739
+ 4. Site and README install/version strings are updated to `v0.9.6`, including release notes index text and any pinned install snippets.
740
+ 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.6`) versus the git tag (`v0.9.6`).
741
741
 
742
742
  ## CLI
743
743
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -129,6 +129,7 @@ export async function PUT(req: Request) {
129
129
  settings.taskQualityGateRequireReport = parseBoolSetting(settings.taskQualityGateRequireReport, false)
130
130
  settings.taskManagementEnabled = parseBoolSetting(settings.taskManagementEnabled, true)
131
131
  settings.projectManagementEnabled = parseBoolSetting(settings.projectManagementEnabled, true)
132
+ settings.walletApprovalsEnabled = parseBoolSetting(settings.walletApprovalsEnabled, true)
132
133
  settings.integrityMonitorEnabled = parseBoolSetting(settings.integrityMonitorEnabled, true)
133
134
  settings.daemonAutostartEnabled = parseBoolSetting(settings.daemonAutostartEnabled, true)
134
135
  settings.sessionResetMode = settings.sessionResetMode === 'daily' ? 'daily' : settings.sessionResetMode === 'idle' ? 'idle' : null
@@ -1,12 +1,17 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
3
+ import { loadSettings, loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
4
4
  import { notify } from '@/lib/server/ws-hub'
5
5
  import type { AgentWallet, WalletTransaction } from '@/types'
6
6
  import {
7
7
  normalizeAtomicString,
8
8
  } from '@/lib/wallet/wallet'
9
- import { isValidWalletAddress, sendWalletNativeAsset, validateWalletSendLimits } from '@/lib/server/wallet/wallet-service'
9
+ import {
10
+ isValidWalletAddress,
11
+ sendWalletNativeAsset,
12
+ validateWalletSendLimits,
13
+ walletRequiresApproval,
14
+ } from '@/lib/server/wallet/wallet-service'
10
15
  import { errorMessage } from '@/lib/shared-utils'
11
16
  export const dynamic = 'force-dynamic'
12
17
 
@@ -15,6 +20,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
15
20
  const wallets = loadWallets() as Record<string, AgentWallet>
16
21
  const wallet = wallets[id]
17
22
  if (!wallet) return NextResponse.json({ error: 'Wallet not found' }, { status: 404 })
23
+ const settings = loadSettings()
18
24
 
19
25
  const body = await req.json()
20
26
  const toAddress = typeof body.toAddress === 'string' ? body.toAddress.trim() : ''
@@ -32,8 +38,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
32
38
  const txId = genId(8)
33
39
  const now = Date.now()
34
40
 
35
- // If requireApproval, create pending tx and return it
36
- if (wallet.requireApproval) {
41
+ // When approvals are enabled globally and for this wallet, create a pending request instead of sending.
42
+ if (walletRequiresApproval(wallet, settings)) {
37
43
  const pendingTx: WalletTransaction = {
38
44
  id: txId,
39
45
  walletId: id,
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadAgents, loadWallets } from '@/lib/server/storage'
2
+ import { loadAgents, loadSettings, loadWallets } from '@/lib/server/storage'
3
3
  import { createAgentWallet, getAgentActiveWalletId, getWalletPortfolioSnapshot, stripWalletPrivateKey } from '@/lib/server/wallet/wallet-service'
4
4
  import { buildEmptyWalletPortfolio } from '@/lib/server/wallet/wallet-portfolio'
5
5
  import type { AgentWallet, WalletPortfolioSummary } from '@/types'
@@ -62,13 +62,16 @@ export async function GET(req: Request) {
62
62
 
63
63
  export async function POST(req: Request) {
64
64
  const body = await req.json()
65
+ const settings = loadSettings()
65
66
  try {
66
67
  const wallet = createAgentWallet({
67
68
  agentId: body.agentId,
68
69
  chain: body.chain,
69
70
  provider: body.provider,
70
71
  label: body.label,
71
- requireApproval: body.requireApproval,
72
+ requireApproval: typeof body.requireApproval === 'boolean'
73
+ ? body.requireApproval
74
+ : settings.walletApprovalsEnabled !== false,
72
75
  spendingLimitAtomic: body.spendingLimitAtomic ?? body.spendingLimitLamports,
73
76
  dailyLimitAtomic: body.dailyLimitAtomic ?? body.dailyLimitLamports,
74
77
  })
@@ -11,6 +11,7 @@ import { ThemeSection } from '@/views/settings/section-theme'
11
11
  import { OrchestratorSection } from '@/views/settings/section-orchestrator'
12
12
  import { RuntimeLoopSection } from '@/views/settings/section-runtime-loop'
13
13
  import { CapabilityPolicySection } from '@/views/settings/section-capability-policy'
14
+ import { WalletsSection } from '@/views/settings/section-wallets'
14
15
  import { StorageSection } from '@/views/settings/section-storage'
15
16
  import { VoiceSection } from '@/views/settings/section-voice'
16
17
  import { WebSearchSection } from '@/views/settings/section-web-search'
@@ -147,6 +148,14 @@ export default function SettingsRoute() {
147
148
  keywords: ['storage', 'uploads', 'disk', 'cleanup', 'files'],
148
149
  render: () => <StorageSection {...sectionProps} />,
149
150
  },
151
+ {
152
+ id: 'wallets',
153
+ tabId: 'general',
154
+ title: 'Wallets',
155
+ description: 'Control global wallet approval behavior and auto-execution defaults.',
156
+ keywords: ['wallet', 'wallets', 'approval', 'approvals', 'crypto', 'send'],
157
+ render: () => <WalletsSection {...sectionProps} />,
158
+ },
150
159
  {
151
160
  id: 'theme',
152
161
  tabId: 'appearance',
@@ -114,6 +114,7 @@ function suggestCreateChain(wallets: SafeWallet[], agentId?: string | null): Wal
114
114
 
115
115
  export function WalletPanel() {
116
116
  const agents = useAppStore((s) => s.agents)
117
+ const appSettings = useAppStore((s) => s.appSettings)
117
118
  const walletPanelAgentId = useAppStore((s) => s.walletPanelAgentId)
118
119
  const setWalletPanelAgentId = useAppStore((s) => s.setWalletPanelAgentId)
119
120
  const navigateTo = useNavigate()
@@ -354,6 +355,7 @@ export function WalletPanel() {
354
355
  })
355
356
  const selectedWalletMeta = selectedWallet ? getWalletChainMeta(selectedWallet.chain) : null
356
357
  const selectedWalletSymbol = selectedWallet ? getWalletAssetSymbol(selectedWallet.chain) : null
358
+ const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
357
359
  const selectedWalletBalance = selectedWallet ? walletBalanceLabel(selectedWallet) : null
358
360
  const selectedWalletAssets = (selectedWallet?.assets || []).filter((asset) => BigInt(asset.balanceAtomic) > BigInt(0))
359
361
  const selectedAgent = selectedWallet ? agents[selectedWallet.agentId] as Agent | undefined : undefined
@@ -807,6 +809,11 @@ export function WalletPanel() {
807
809
  </button>
808
810
  <span className="text-[11px] text-text-3">Require approval for sends</span>
809
811
  </div>
812
+ {!walletApprovalsEnabled && (
813
+ <p className="text-[10px] text-amber-300/80">
814
+ Global wallet approvals are currently off in Settings, so this per-wallet toggle is ignored until they are turned back on.
815
+ </p>
816
+ )}
810
817
  <div className="flex gap-2 pt-1">
811
818
  <button
812
819
  type="button"
@@ -839,7 +846,11 @@ export function WalletPanel() {
839
846
  </div>
840
847
  <div className="flex justify-between">
841
848
  <span className="text-text-3/70">Approval</span>
842
- <span className="text-text-2">{selectedWallet.requireApproval ? 'Required' : 'Auto-send'}</span>
849
+ <span className="text-text-2">
850
+ {!walletApprovalsEnabled
851
+ ? 'Disabled globally'
852
+ : (selectedWallet.requireApproval ? 'Required' : 'Auto-send')}
853
+ </span>
843
854
  </div>
844
855
  </div>
845
856
  )}
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { api } from '@/lib/app/api-client'
5
5
  import { copyTextToClipboard } from '@/lib/clipboard'
6
+ import { useAppStore } from '@/stores/use-app-store'
6
7
  import type { AgentWallet, WalletAssetBalance, WalletPortfolioSummary, WalletChain } from '@/types'
7
8
  import { toast } from 'sonner'
8
9
  import { errorMessage } from '@/lib/shared-utils'
@@ -32,6 +33,7 @@ interface WalletSectionProps {
32
33
  }
33
34
 
34
35
  export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreated }: WalletSectionProps) {
36
+ const appSettings = useAppStore((s) => s.appSettings)
35
37
  const [creating, setCreating] = useState(false)
36
38
  const [activatingWalletId, setActivatingWalletId] = useState<string | null>(null)
37
39
  const [error, setError] = useState<string | null>(null)
@@ -53,6 +55,7 @@ export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreate
53
55
  )
54
56
 
55
57
  const [chain, setChain] = useState<WalletChain>(availableChains[0] || 'solana')
58
+ const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
56
59
 
57
60
  useEffect(() => {
58
61
  if (availableChains.length === 0) return
@@ -204,7 +207,7 @@ export function WalletSection({ agentId, wallets, activeWalletId, onWalletCreate
204
207
  <div className="flex flex-wrap items-center gap-3 text-[10px] text-text-3/60">
205
208
  <span>Limit: {perTxLimit} {walletMeta.symbol}/tx</span>
206
209
  <span>Daily: {dailyLimit} {walletMeta.symbol}</span>
207
- <span>{wallet.requireApproval ? 'Approval required' : 'Auto-send'}</span>
210
+ <span>{!walletApprovalsEnabled ? 'Approvals off globally' : (wallet.requireApproval ? 'Approval required' : 'Auto-send')}</span>
208
211
  {wallet.portfolioSummary?.nonZeroAssets ? (
209
212
  <span>{wallet.portfolioSummary.nonZeroAssets} asset{wallet.portfolioSummary.nonZeroAssets === 1 ? '' : 's'} detected</span>
210
213
  ) : null}
@@ -195,6 +195,41 @@ describe('main-agent-loop advanced', () => {
195
195
  assert.equal(output.followupOk, null, 'no followup on terminal ack')
196
196
  })
197
197
 
198
+ it('allows a bounded followup for chat-originated runs when structured progress is still active', () => {
199
+ const progressMeta = heartbeatMetaLine('progress', 'buy nft', 'prepare the first safe wallet step')
200
+ const output = runWithTempDataDir(`
201
+ ${sessionSetupScript()}
202
+
203
+ const followup = mainLoop.handleMainLoopRunResult({
204
+ sessionId: 'main',
205
+ message: 'Try buy one NFT and show me what happened.',
206
+ internal: false,
207
+ source: 'chat',
208
+ resultText: \`I found the contract and I am moving to the first safe execution step.\\n${progressMeta}\`,
209
+ toolEvents: [{
210
+ name: 'wallet_tool',
211
+ input: '{"action":"balance"}',
212
+ output: '{"status":"ok"}',
213
+ }],
214
+ })
215
+ const state = mainLoop.getMainLoopStateForSession('main')
216
+
217
+ console.log(JSON.stringify({
218
+ hasFollowup: followup !== null,
219
+ followupMessage: followup?.message ?? null,
220
+ chain: state?.followupChainCount ?? -1,
221
+ status: state?.status ?? null,
222
+ nextAction: state?.nextAction ?? null,
223
+ }))
224
+ `)
225
+
226
+ assert.equal(output.hasFollowup, true, 'chat run should queue one bounded followup')
227
+ assert.equal(output.chain, 1, 'chat followup starts the chain at 1')
228
+ assert.equal(output.status, 'progress')
229
+ assert.equal(output.nextAction, 'prepare the first safe wallet step')
230
+ assert.match(String(output.followupMessage || ''), /Resume from this next action/)
231
+ })
232
+
198
233
  it('persists and upgrades a skill blocker across recommend/install steps', () => {
199
234
  const output = runWithTempDataDir(`
200
235
  ${sessionSetupScript()}
@@ -894,9 +894,20 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
894
894
 
895
895
  const needsReplan = review?.needs_replan === true || ((review?.confidence ?? 1) < 0.45)
896
896
  const limit = followupLimit()
897
+ const allowChatOriginFollowup = !input.internal
898
+ && input.source === 'chat'
899
+ && !input.error
900
+ && !waitingForExternal
901
+ && !gotTerminalAck
902
+ && (
903
+ needsReplan
904
+ || heartbeat?.status === 'progress'
905
+ || !!heartbeat?.nextAction
906
+ || (!!plan?.current_step && toolNames.length > 0)
907
+ )
897
908
 
898
909
  let followup: MainLoopFollowupRequest | null = null
899
- if (!input.internal || input.source === 'chat') {
910
+ if (!input.internal && !allowChatOriginFollowup) {
900
911
  state.followupChainCount = 0
901
912
  } else if (input.error || waitingForExternal || gotTerminalAck) {
902
913
  state.followupChainCount = 0
@@ -15,6 +15,10 @@ let runtime: typeof import('@/lib/server/agents/subagent-runtime')
15
15
  let lineage: typeof import('@/lib/server/agents/subagent-lineage')
16
16
  let delegationJobs: typeof import('@/lib/server/agents/delegation-jobs')
17
17
  let storage: typeof import('@/lib/server/storage')
18
+ let pluginManager: {
19
+ registerBuiltin: (id: string, plugin: Record<string, unknown>) => void
20
+ }
21
+ let providers: Record<string, unknown>
18
22
 
19
23
  before(async () => {
20
24
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-subagent-runtime-'))
@@ -26,6 +30,9 @@ before(async () => {
26
30
  delegationJobs = await import('@/lib/server/agents/delegation-jobs')
27
31
  lineage = await import('@/lib/server/agents/subagent-lineage')
28
32
  runtime = await import('@/lib/server/agents/subagent-runtime')
33
+ pluginManager = (await import('@/lib/server/plugins')).getPluginManager()
34
+ const providersMod = await import('@/lib/providers/index')
35
+ providers = providersMod.PROVIDERS
29
36
  })
30
37
 
31
38
  after(() => {
@@ -57,9 +64,9 @@ describe('subagent-runtime', () => {
57
64
  })
58
65
 
59
66
  describe('spawnSubagent', () => {
60
- it('throws for unknown agent', () => {
67
+ it('throws for unknown agent', async () => {
61
68
  lineage._clearLineage()
62
- assert.throws(
69
+ await assert.rejects(
63
70
  () => runtime.spawnSubagent(
64
71
  { agentId: 'nonexistent', message: 'hello' },
65
72
  { cwd: tempDir },
@@ -68,7 +75,7 @@ describe('subagent-runtime', () => {
68
75
  )
69
76
  })
70
77
 
71
- it('throws when max depth is exceeded', () => {
78
+ it('throws when max depth is exceeded', async () => {
72
79
  lineage._clearLineage()
73
80
  seedAgent('depth-agent', 'Depth Agent')
74
81
 
@@ -80,7 +87,7 @@ describe('subagent-runtime', () => {
80
87
  sessions['depth-s3'] = { id: 'depth-s3', parentSessionId: 'depth-s2', cwd: tempDir }
81
88
  storage.saveSessions(sessions)
82
89
 
83
- assert.throws(
90
+ await assert.rejects(
84
91
  () => runtime.spawnSubagent(
85
92
  { agentId: 'depth-agent', message: 'too deep' },
86
93
  { sessionId: 'depth-s3', cwd: tempDir },
@@ -89,13 +96,13 @@ describe('subagent-runtime', () => {
89
96
  )
90
97
  })
91
98
 
92
- it('creates session, lineage node, and delegation job', () => {
99
+ it('creates session, lineage node, and delegation job', async () => {
93
100
  lineage._clearLineage()
94
101
  seedAgent('spawn-agent', 'Spawn Agent')
95
102
 
96
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
103
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
97
104
  try {
98
- handle = runtime.spawnSubagent(
105
+ handle = await runtime.spawnSubagent(
99
106
  { agentId: 'spawn-agent', message: 'test task', waitForCompletion: false },
100
107
  { sessionId: undefined, cwd: tempDir },
101
108
  )
@@ -132,7 +139,7 @@ describe('subagent-runtime', () => {
132
139
  }
133
140
  })
134
141
 
135
- it('tracks parent-child lineage correctly', () => {
142
+ it('tracks parent-child lineage correctly', async () => {
136
143
  lineage._clearLineage()
137
144
  seedAgent('parent-agent', 'Parent')
138
145
  seedAgent('child-agent', 'Child')
@@ -155,9 +162,9 @@ describe('subagent-runtime', () => {
155
162
  task: 'Parent task',
156
163
  })
157
164
 
158
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
165
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
159
166
  try {
160
- handle = runtime.spawnSubagent(
167
+ handle = await runtime.spawnSubagent(
161
168
  { agentId: 'child-agent', message: 'child task', waitForCompletion: false },
162
169
  { sessionId: 'parent-session', cwd: tempDir },
163
170
  )
@@ -184,6 +191,82 @@ describe('subagent-runtime', () => {
184
191
  assert.equal(ancestors[0].sessionId, 'parent-session')
185
192
  }
186
193
  })
194
+
195
+ it('fires subagent lifecycle hooks through the native runtime', async () => {
196
+ lineage._clearLineage()
197
+ seedAgent('parent-hook-agent', 'Parent Hook Agent', ['subagent_lifecycle_test'])
198
+ seedAgent('child-hook-agent', 'Child Hook Agent')
199
+
200
+ const marks: string[] = []
201
+ pluginManager.registerBuiltin('subagent_lifecycle_test', {
202
+ name: 'Subagent Lifecycle Test',
203
+ hooks: {
204
+ subagentSpawning: ({ agentId }) => {
205
+ marks.push(`spawning:${agentId}`)
206
+ return { status: 'ok' }
207
+ },
208
+ subagentSpawned: ({ childSessionId }) => {
209
+ marks.push(`spawned:${childSessionId}`)
210
+ },
211
+ subagentEnded: ({ status }) => {
212
+ marks.push(`ended:${status}`)
213
+ },
214
+ sessionEnd: ({ reason }) => {
215
+ marks.push(`session_end:${reason}`)
216
+ },
217
+ },
218
+ })
219
+
220
+ providers['subagent-test-provider'] = {
221
+ id: 'subagent-test-provider',
222
+ name: 'Subagent Test Provider',
223
+ models: ['unit'],
224
+ requiresApiKey: false,
225
+ requiresEndpoint: false,
226
+ handler: {
227
+ async streamChat() {
228
+ return 'subagent finished'
229
+ },
230
+ },
231
+ }
232
+
233
+ const agents = storage.loadAgents()
234
+ agents['child-hook-agent'] = {
235
+ id: 'child-hook-agent',
236
+ name: 'Child Hook Agent',
237
+ provider: 'subagent-test-provider',
238
+ model: 'unit',
239
+ systemPrompt: 'Child runtime test',
240
+ }
241
+ storage.saveAgents(agents)
242
+
243
+ const sessions = storage.loadSessions()
244
+ sessions['hook-parent-session'] = {
245
+ id: 'hook-parent-session',
246
+ cwd: tempDir,
247
+ parentSessionId: null,
248
+ agentId: 'parent-hook-agent',
249
+ provider: 'subagent-test-provider',
250
+ model: 'unit',
251
+ plugins: ['subagent_lifecycle_test'],
252
+ messages: [],
253
+ createdAt: Date.now(),
254
+ lastActiveAt: Date.now(),
255
+ }
256
+ storage.saveSessions(sessions)
257
+
258
+ const handle = await runtime.spawnSubagent(
259
+ { agentId: 'child-hook-agent', message: 'finish the task', waitForCompletion: false },
260
+ { sessionId: 'hook-parent-session', cwd: tempDir },
261
+ )
262
+ const result = await handle.promise
263
+
264
+ assert.equal(result.status, 'completed')
265
+ assert.equal(marks.includes('spawning:child-hook-agent'), true)
266
+ assert.equal(marks.some((mark) => mark.startsWith('spawned:')), true)
267
+ assert.equal(marks.includes('ended:completed'), true)
268
+ assert.equal(marks.includes('session_end:completed'), true)
269
+ })
187
270
  })
188
271
 
189
272
  describe('mergePlugins', () => {
@@ -230,7 +313,7 @@ describe('subagent-runtime', () => {
230
313
  })
231
314
 
232
315
  describe('plugin inheritance in spawnSubagent', () => {
233
- it('child session inherits parent plugins merged with agent plugins', () => {
316
+ it('child session inherits parent plugins merged with agent plugins', async () => {
234
317
  lineage._clearLineage()
235
318
  seedAgent('inherit-agent', 'Inherit Agent', ['shell', 'memory'])
236
319
 
@@ -244,9 +327,9 @@ describe('subagent-runtime', () => {
244
327
  }
245
328
  storage.saveSessions(sessions)
246
329
 
247
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
330
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
248
331
  try {
249
- handle = runtime.spawnSubagent(
332
+ handle = await runtime.spawnSubagent(
250
333
  { agentId: 'inherit-agent', message: 'test inheritance', waitForCompletion: false },
251
334
  { sessionId: 'inherit-parent', cwd: tempDir },
252
335
  )
@@ -265,7 +348,7 @@ describe('subagent-runtime', () => {
265
348
  }
266
349
  })
267
350
 
268
- it('child session does not inherit when inheritPlugins is false', () => {
351
+ it('child session does not inherit when inheritPlugins is false', async () => {
269
352
  lineage._clearLineage()
270
353
  seedAgent('no-inherit-agent', 'No Inherit Agent', ['shell'])
271
354
 
@@ -278,9 +361,9 @@ describe('subagent-runtime', () => {
278
361
  }
279
362
  storage.saveSessions(sessions)
280
363
 
281
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
364
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
282
365
  try {
283
- handle = runtime.spawnSubagent(
366
+ handle = await runtime.spawnSubagent(
284
367
  { agentId: 'no-inherit-agent', message: 'no inherit', inheritPlugins: false, waitForCompletion: false },
285
368
  { sessionId: 'no-inherit-parent', cwd: tempDir },
286
369
  )