@swarmclawai/swarmclaw 0.9.5 → 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.
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.5 Release Readiness Notes
732
+ #### v0.9.6 Release Readiness Notes
733
733
 
734
- Before shipping `v0.9.5`, 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. Plugin/runtime docs mention the new typed lifecycle hooks: `beforePromptBuild`, `beforeToolCall`, `beforeModelResolve`, `llmInput`, `llmOutput`, `toolResultPersist`, `beforeMessageWrite`, plus session and subagent lifecycle hooks.
737
- 2. Connector/memory docs explain that quiet-boundary memories are matched by identifiers and agent aliases, with explicit boundary metadata support, rather than relying on hardcoded person-name fallbacks.
738
- 3. Skills 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.5`, 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.5`) versus the git tag (`v0.9.5`).
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.5",
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
@@ -14,6 +14,7 @@ import {
14
14
  resolveFinalStreamResponseText,
15
15
  shouldSkipToolSummaryForShortResponse,
16
16
  shouldForceAttachmentFollowthrough,
17
+ shouldForceExternalExecutionKickoffFollowthrough,
17
18
  shouldForceRecoverableToolErrorFollowthrough,
18
19
  shouldTerminateOnSuccessfulMemoryMutation,
19
20
  shouldForceDeliverableFollowthrough,
@@ -209,6 +210,12 @@ describe('buildToolDisciplineLines', () => {
209
210
  assert.ok(streamAgentChatSource.includes('did not start the required workspace tool step'))
210
211
  })
211
212
 
213
+ it('wires a bounded execution-kickoff continuation for intent-only live task replies', () => {
214
+ assert.ok(streamSources.includes('execution_kickoff_followthrough'))
215
+ assert.ok(streamAgentChatSource.includes('shouldForceExternalExecutionKickoffFollowthrough'))
216
+ assert.ok(streamAgentChatSource.includes('externalExecutionKickoff'))
217
+ })
218
+
212
219
  it('adds current-thread recall guidance and immediate memory routes in the system prompt', () => {
213
220
  assert.ok(streamAgentChatSource.includes('## Current Thread Recall'))
214
221
  assert.ok(streamAgentChatSource.includes('## Immediate Memory Routes'))
@@ -624,6 +631,41 @@ describe('shouldForceExternalExecutionFollowthrough', () => {
624
631
  })
625
632
  })
626
633
 
634
+ describe('shouldForceExternalExecutionKickoffFollowthrough', () => {
635
+ it('forces a bounded continuation when an execution task stops at an intent-only kickoff', () => {
636
+ assert.equal(
637
+ shouldForceExternalExecutionKickoffFollowthrough({
638
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
639
+ finalResponse: 'Let me try to interact directly with the NFT contract and see if I can mint one:',
640
+ hasToolCalls: false,
641
+ toolEvents: [],
642
+ }),
643
+ true,
644
+ )
645
+ })
646
+
647
+ it('does not force kickoff when the model already surfaced a real blocker or asked a blocking question', () => {
648
+ assert.equal(
649
+ shouldForceExternalExecutionKickoffFollowthrough({
650
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
651
+ finalResponse: 'Exact blocker: this wallet cannot complete the required signature in the current runtime.',
652
+ hasToolCalls: false,
653
+ toolEvents: [],
654
+ }),
655
+ false,
656
+ )
657
+ assert.equal(
658
+ shouldForceExternalExecutionKickoffFollowthrough({
659
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
660
+ finalResponse: 'Which collection do you want me to target?',
661
+ hasToolCalls: false,
662
+ toolEvents: [],
663
+ }),
664
+ false,
665
+ )
666
+ })
667
+ })
668
+
627
669
  describe('shouldForceAttachmentFollowthrough', () => {
628
670
  it('forces a retry for attachment-backed research turns that still skipped tools', () => {
629
671
  assert.equal(
@@ -38,6 +38,7 @@ import {
38
38
  looksLikeBoundedExternalExecutionTask,
39
39
  looksLikeOpenEndedDeliverableTask,
40
40
  shouldForceRecoverableToolErrorFollowthrough,
41
+ shouldForceExternalExecutionKickoffFollowthrough,
41
42
  shouldForceExternalExecutionFollowthrough,
42
43
  shouldForceDeliverableFollowthrough,
43
44
  hasStateChangingWalletEvidence,
@@ -88,6 +89,7 @@ export {
88
89
  getExplicitRequiredToolNames,
89
90
  isWalletSimulationResult,
90
91
  looksLikeOpenEndedDeliverableTask,
92
+ shouldForceExternalExecutionKickoffFollowthrough,
91
93
  shouldForceRecoverableToolErrorFollowthrough,
92
94
  shouldForceExternalExecutionFollowthrough,
93
95
  shouldForceDeliverableFollowthrough,
@@ -1181,6 +1183,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1181
1183
  const MAX_TRANSIENT_RETRIES = 3
1182
1184
  const MAX_REQUIRED_TOOL_CONTINUES = 2
1183
1185
  let MAX_EXECUTION_FOLLOWTHROUGHS = 1
1186
+ const MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS = 1
1184
1187
  let MAX_ATTACHMENT_FOLLOWTHROUGHS = 1
1185
1188
  let MAX_DELIVERABLE_FOLLOWTHROUGHS = 2
1186
1189
  let MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS = 2
@@ -1202,6 +1205,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1202
1205
  let pendingRetryAfterMs: number | null = null
1203
1206
  let requiredToolContinueCount = 0
1204
1207
  let executionFollowthroughCount = 0
1208
+ let executionKickoffFollowthroughCount = 0
1205
1209
  let attachmentFollowthroughCount = 0
1206
1210
  let deliverableFollowthroughCount = 0
1207
1211
  let unfinishedToolFollowthroughCount = 0
@@ -1215,7 +1219,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1215
1219
  let terminalToolResponse = ''
1216
1220
 
1217
1221
  try {
1218
- const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
1222
+ const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
1219
1223
  for (let iteration = 0; iteration <= maxIterations; iteration++) {
1220
1224
  let shouldContinue: ContinuationType = false
1221
1225
  let requiredToolReminderNames: string[] = []
@@ -1766,6 +1770,31 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1766
1770
  })}\n\n`)
1767
1771
  }
1768
1772
 
1773
+ if (!shouldContinue
1774
+ && executionKickoffFollowthroughCount < MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS
1775
+ && shouldForceExternalExecutionKickoffFollowthrough({
1776
+ userMessage: message,
1777
+ finalResponse: resolveFinalStreamResponseText({
1778
+ fullText,
1779
+ lastSegment,
1780
+ lastSettledSegment,
1781
+ hasToolCalls,
1782
+ toolEvents: streamedToolEvents,
1783
+ }),
1784
+ hasToolCalls,
1785
+ toolEvents: streamedToolEvents,
1786
+ })) {
1787
+ shouldContinue = 'execution_kickoff_followthrough'
1788
+ executionKickoffFollowthroughCount++
1789
+ write(`data: ${JSON.stringify({
1790
+ t: 'status',
1791
+ text: JSON.stringify({
1792
+ externalExecutionKickoff: executionKickoffFollowthroughCount,
1793
+ maxFollowthroughs: MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS,
1794
+ }),
1795
+ })}\n\n`)
1796
+ }
1797
+
1769
1798
  if (!shouldContinue
1770
1799
  && executionFollowthroughCount < MAX_EXECUTION_FOLLOWTHROUGHS
1771
1800
  && shouldForceExternalExecutionFollowthrough({
@@ -20,6 +20,7 @@ export type ContinuationType =
20
20
  | 'transient'
21
21
  | 'required_tool'
22
22
  | 'attachment_followthrough'
23
+ | 'execution_kickoff_followthrough'
23
24
  | 'execution_followthrough'
24
25
  | 'deliverable_followthrough'
25
26
  | 'unfinished_tool_followthrough'
@@ -237,6 +238,28 @@ export function shouldForceExternalExecutionFollowthrough(params: {
237
238
  return /(let me|i'll|i will|trying|research|query|check|look|promising|now let me|good -|good,)/i.test(trimmed) || trimmed.length < 500
238
239
  }
239
240
 
241
+ export function shouldForceExternalExecutionKickoffFollowthrough(params: {
242
+ userMessage: string
243
+ finalResponse: string
244
+ hasToolCalls: boolean
245
+ toolEvents: MessageToolEvent[]
246
+ }): boolean {
247
+ if (!looksLikeBoundedExternalExecutionTask(params.userMessage)) return false
248
+ if (params.hasToolCalls || params.toolEvents.length > 0) return false
249
+
250
+ const trimmed = params.finalResponse.trim()
251
+ if (!trimmed) return true
252
+ if (/^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i.test(trimmed)) return false
253
+ if (/\?\s*$/.test(trimmed)) return false
254
+ if (/\b(last reversible step|exact blocker|blocked|cannot|can't|missing capability|need approval|requires approval|approval boundary|requires human|ask_human|credential|authentication|login|2fa|mfa|captcha)\b/i.test(trimmed)) {
255
+ return false
256
+ }
257
+ if (/\b(done|completed|finished|sent|broadcast|minted|purchased|bought|swapped|claimed)\b/i.test(trimmed)) {
258
+ return false
259
+ }
260
+ return looksLikeIncompleteDeliverableResponse(trimmed) || trimmed.length < 220
261
+ }
262
+
240
263
  export function shouldForceDeliverableFollowthrough(params: {
241
264
  userMessage: string
242
265
  finalResponse: string
@@ -360,6 +383,23 @@ function buildExternalExecutionFollowthroughPrompt(params: {
360
383
  ].join('\n')
361
384
  }
362
385
 
386
+ function buildExternalExecutionKickoffPrompt(params: {
387
+ userMessage: string
388
+ fullText: string
389
+ }): string {
390
+ return [
391
+ 'The previous iteration stopped after an intent update before taking the first concrete execution step.',
392
+ 'Do not send another preamble like "let me check", "I will try", or "I\'m going to".',
393
+ 'Continue immediately from the same objective and take the first concrete reversible step now using the available tools.',
394
+ 'If a real blocker appears before any safe action, state the exact blocker with evidence instead of narrating your plan.',
395
+ 'Do not ask the user to repeat the task. Either act now or report the blocker.',
396
+ '',
397
+ `Objective:\n${params.userMessage}`,
398
+ '',
399
+ `Previous response:\n${params.fullText || '(none)'}`,
400
+ ].join('\n')
401
+ }
402
+
363
403
  function buildDeliverableFollowthroughPrompt(params: {
364
404
  userMessage: string
365
405
  fullText: string
@@ -578,6 +618,12 @@ export function buildContinuationPrompt(params: {
578
618
  fullText: params.fullText,
579
619
  })
580
620
 
621
+ case 'execution_kickoff_followthrough':
622
+ return buildExternalExecutionKickoffPrompt({
623
+ userMessage: params.message,
624
+ fullText: params.fullText,
625
+ })
626
+
581
627
  case 'execution_followthrough':
582
628
  return buildExternalExecutionFollowthroughPrompt({
583
629
  userMessage: params.message,
@@ -3,7 +3,11 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import type { AgentWallet, WalletTransaction } from '@/types'
5
5
 
6
- import { validateWalletSendLimits } from '@/lib/server/wallet/wallet-service'
6
+ import {
7
+ validateWalletSendLimits,
8
+ walletApprovalsGloballyEnabled,
9
+ walletRequiresApproval,
10
+ } from '@/lib/server/wallet/wallet-service'
7
11
 
8
12
  function buildWallet(overrides: Partial<AgentWallet> = {}): AgentWallet {
9
13
  return {
@@ -55,3 +59,23 @@ describe('validateWalletSendLimits', () => {
55
59
  assert.match(error || '', /Daily limit exceeded/)
56
60
  })
57
61
  })
62
+
63
+ describe('wallet approval helpers', () => {
64
+ it('treats missing app setting as globally enabled', () => {
65
+ assert.equal(walletApprovalsGloballyEnabled(undefined), true)
66
+ assert.equal(walletApprovalsGloballyEnabled({}), true)
67
+ })
68
+
69
+ it('lets the global setting disable approval even for approval-required wallets', () => {
70
+ const wallet = buildWallet({ requireApproval: true })
71
+
72
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: true }), true)
73
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: false }), false)
74
+ })
75
+
76
+ it('still respects the per-wallet toggle when global approvals remain enabled', () => {
77
+ const wallet = buildWallet({ requireApproval: false })
78
+
79
+ assert.equal(walletRequiresApproval(wallet, { walletApprovalsEnabled: true }), false)
80
+ })
81
+ })
@@ -91,6 +91,19 @@ export function getWalletByAgentId(agentId: string, chain?: WalletChain | null):
91
91
  return wallets.find((wallet) => wallet.id === activeWalletId) ?? wallets[0] ?? null
92
92
  }
93
93
 
94
+ export function walletApprovalsGloballyEnabled(
95
+ settings?: { walletApprovalsEnabled?: boolean | null } | null,
96
+ ): boolean {
97
+ return settings?.walletApprovalsEnabled !== false
98
+ }
99
+
100
+ export function walletRequiresApproval(
101
+ wallet: Pick<AgentWallet, 'requireApproval'>,
102
+ settings?: { walletApprovalsEnabled?: boolean | null } | null,
103
+ ): boolean {
104
+ return walletApprovalsGloballyEnabled(settings) && wallet.requireApproval !== false
105
+ }
106
+
94
107
  export function createAgentWallet(input: {
95
108
  agentId: string
96
109
  chain?: WalletChain | string | null
@@ -904,7 +904,7 @@ export interface AgentWallet {
904
904
  spendingLimitLamports?: number
905
905
  /** @deprecated Use dailyLimitAtomic */
906
906
  dailyLimitLamports?: number
907
- requireApproval: boolean // default true
907
+ requireApproval: boolean // default true; can be globally overridden by app settings
908
908
  createdAt: number
909
909
  updatedAt: number
910
910
  }
@@ -1457,6 +1457,7 @@ export interface AppSettings {
1457
1457
  safetyRequireApprovalForOutbound?: boolean
1458
1458
  safetyMaxDailySpendUsd?: number | null
1459
1459
  safetyBlockedTools?: string[]
1460
+ walletApprovalsEnabled?: boolean
1460
1461
  capabilityPolicyMode?: 'permissive' | 'balanced' | 'strict'
1461
1462
  capabilityBlockedTools?: string[]
1462
1463
  capabilityBlockedCategories?: string[]
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import type { SettingsSectionProps } from './types'
4
+
5
+ export function WalletsSection({ appSettings, patchSettings }: SettingsSectionProps) {
6
+ const walletApprovalsEnabled = appSettings.walletApprovalsEnabled !== false
7
+
8
+ return (
9
+ <div className="mb-10">
10
+ <h3 className="font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
11
+ Wallets
12
+ </h3>
13
+ <p className="text-[12px] text-text-3 mb-5">
14
+ Global override for wallet approval prompts. Turn this off to auto-execute wallet sends and other wallet actions without creating pending approval steps.
15
+ </p>
16
+
17
+ <div className="flex items-center justify-between rounded-[14px] border border-white/[0.06] bg-white/[0.03] px-4 py-3">
18
+ <div className="pr-4">
19
+ <label className="text-[12px] font-600 text-text-2 block">Wallet Approvals</label>
20
+ <p className="text-[11px] text-text-3/60 mt-0.5">
21
+ When disabled, wallet actions bypass approval gates globally. Per-wallet approval toggles remain stored, but they are ignored until this is turned back on.
22
+ </p>
23
+ </div>
24
+ <button
25
+ type="button"
26
+ onClick={() => patchSettings({ walletApprovalsEnabled: !walletApprovalsEnabled })}
27
+ className={`relative w-9 h-5 rounded-full transition-colors ${walletApprovalsEnabled ? 'bg-accent-bright' : 'bg-white/[0.10]'}`}
28
+ style={{ fontFamily: 'inherit' }}
29
+ >
30
+ <span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${walletApprovalsEnabled ? 'translate-x-4' : ''}`} />
31
+ </button>
32
+ </div>
33
+ </div>
34
+ )
35
+ }