1+ using OpenClaw . Connection ;
12using OpenClaw . Shared ;
23using System ;
34using System . Collections . Generic ;
5+ using System . IO ;
46using System . Text . Json ;
57using System . Threading ;
68using System . Threading . Tasks ;
@@ -10,6 +12,22 @@ namespace OpenClawTray.Services;
1012public static class OnboardingChatBootstrapper
1113{
1214 private static int s_inFlight ;
15+ private static readonly TimeSpan ExistingWorkspaceProbeTimeout = TimeSpan . FromSeconds ( 3 ) ;
16+ private static readonly HashSet < string > ExistingWorkspaceMarkerFiles = new ( StringComparer . Ordinal )
17+ {
18+ "SOUL.md" ,
19+ "IDENTITY.md" ,
20+ "USER.md" ,
21+ "HEARTBEAT.md" ,
22+ "MEMORY.md" ,
23+ } ;
24+
25+ private enum ExistingWorkspaceState
26+ {
27+ Unknown ,
28+ Empty ,
29+ Existing ,
30+ }
1331
1432 public const string Message =
1533 "Hi! I just installed OpenClaw and you're my brand-new agent. " +
@@ -36,14 +54,47 @@ public static async Task<bool> BootstrapAsync(
3654 IOperatorGatewayClient ? client ,
3755 SettingsManager settings ,
3856 TimeSpan ? completionTimeout = null ,
39- CancellationToken cancellationToken = default )
57+ CancellationToken cancellationToken = default ,
58+ GatewayRegistry ? registry = null ,
59+ TimeSpan ? existingWorkspaceProbeTimeout = null )
4060 {
4161 ArgumentNullException . ThrowIfNull ( settings ) ;
4262
4363 if ( settings . HasInjectedFirstRunBootstrap )
4464 return true ;
65+
4566 if ( client == null || ! client . IsConnectedToGateway )
4667 return false ;
68+
69+ // A saved gateway credential is not enough to suppress hatching: fresh local setup
70+ // creates registry-backed credentials before the first-run prompt has been sent.
71+ // Only consume the gate when the connected workspace already contains durable
72+ // OpenClaw state that the bootstrap ritual would otherwise rewrite.
73+ if ( registry is not null &&
74+ SetupExistingGatewayClassifier . HasAnyExistingGatewayConnection (
75+ registry ,
76+ settings ,
77+ settings . SettingsDirectory ) )
78+ {
79+ var workspaceState = await ProbeExistingWorkspaceStateAsync (
80+ client ,
81+ existingWorkspaceProbeTimeout ?? ExistingWorkspaceProbeTimeout ,
82+ cancellationToken ) . ConfigureAwait ( true ) ;
83+
84+ if ( workspaceState == ExistingWorkspaceState . Existing )
85+ {
86+ MarkBootstrapped ( settings ) ;
87+ Logger . Info ( "[OnboardingChatBootstrapper] Existing OpenClaw workspace state detected; skipping first-run bootstrap prompt." ) ;
88+ return true ;
89+ }
90+
91+ if ( workspaceState == ExistingWorkspaceState . Unknown )
92+ {
93+ Logger . Warn ( "[OnboardingChatBootstrapper] Workspace state probe was unavailable; not sending first-run bootstrap automatically." ) ;
94+ return false ;
95+ }
96+ }
97+
4798 if ( Interlocked . CompareExchange ( ref s_inFlight , 1 , 0 ) != 0 )
4899 {
49100 Logger . Info ( "[OnboardingChatBootstrapper] Bootstrap skipped because another gateway send is in flight" ) ;
@@ -94,6 +145,122 @@ public static async Task<bool> BootstrapAsync(
94145 }
95146 }
96147
148+ private static async Task < ExistingWorkspaceState > ProbeExistingWorkspaceStateAsync (
149+ IOperatorGatewayClient client ,
150+ TimeSpan timeout ,
151+ CancellationToken cancellationToken )
152+ {
153+ const string agentId = "main" ;
154+ using var observer = new AgentFilesListObserver ( client , agentId ) ;
155+ try
156+ {
157+ await client . RequestAgentFilesListAsync ( agentId ) . ConfigureAwait ( true ) ;
158+ }
159+ catch ( OperationCanceledException )
160+ {
161+ throw ;
162+ }
163+ catch ( Exception ex )
164+ {
165+ Logger . Warn ( $ "[OnboardingChatBootstrapper] Workspace state probe failed: { ex . Message } ") ;
166+ return ExistingWorkspaceState . Unknown ;
167+ }
168+
169+ var payload = await observer . WaitForFilesListAsync (
170+ DateTimeOffset . UtcNow + timeout ,
171+ cancellationToken ) . ConfigureAwait ( true ) ;
172+
173+ if ( payload is null )
174+ {
175+ Logger . Warn ( "[OnboardingChatBootstrapper] Workspace state probe returned no file list." ) ;
176+ return ExistingWorkspaceState . Unknown ;
177+ }
178+
179+ return ContainsExistingWorkspaceMarker ( payload . Value )
180+ ? ExistingWorkspaceState . Existing
181+ : ExistingWorkspaceState . Empty ;
182+ }
183+
184+ private static bool ContainsExistingWorkspaceMarker ( JsonElement payload )
185+ {
186+ if ( ! payload . TryGetProperty ( "files" , out var filesEl ) || filesEl . ValueKind != JsonValueKind . Array )
187+ return false ;
188+
189+ foreach ( var fileEl in filesEl . EnumerateArray ( ) )
190+ {
191+ var exists = ! fileEl . TryGetProperty ( "exists" , out var existsEl ) ||
192+ existsEl . ValueKind != JsonValueKind . False ;
193+ if ( ! exists )
194+ continue ;
195+
196+ if ( ! fileEl . TryGetProperty ( "name" , out var nameEl ) )
197+ continue ;
198+
199+ var name = nameEl . GetString ( ) ;
200+ if ( string . IsNullOrWhiteSpace ( name ) )
201+ continue ;
202+
203+ if ( ExistingWorkspaceMarkerFiles . Contains ( Path . GetFileName ( name ) ) )
204+ return true ;
205+ }
206+
207+ return false ;
208+ }
209+
210+ private sealed class AgentFilesListObserver : IDisposable
211+ {
212+ private readonly IOperatorGatewayClient _client ;
213+ private readonly string _agentId ;
214+ private readonly TaskCompletionSource < JsonElement ? > _completion = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
215+
216+ public AgentFilesListObserver ( IOperatorGatewayClient client , string agentId )
217+ {
218+ _client = client ;
219+ _agentId = agentId ;
220+ _client . AgentFilesListUpdated += OnAgentFilesListUpdated ;
221+ }
222+
223+ public async Task < JsonElement ? > WaitForFilesListAsync (
224+ DateTimeOffset timeoutAt ,
225+ CancellationToken cancellationToken )
226+ {
227+ if ( _completion . Task . IsCompleted )
228+ return await _completion . Task . ConfigureAwait ( true ) ;
229+
230+ var remaining = timeoutAt - DateTimeOffset . UtcNow ;
231+ if ( remaining <= TimeSpan . Zero )
232+ return null ;
233+
234+ var completed = await Task . WhenAny ( _completion . Task , Task . Delay ( remaining , cancellationToken ) ) . ConfigureAwait ( true ) ;
235+ if ( completed != _completion . Task )
236+ {
237+ cancellationToken . ThrowIfCancellationRequested ( ) ;
238+ return null ;
239+ }
240+
241+ return await _completion . Task . ConfigureAwait ( true ) ;
242+ }
243+
244+ public void Dispose ( )
245+ {
246+ _client . AgentFilesListUpdated -= OnAgentFilesListUpdated ;
247+ }
248+
249+ private void OnAgentFilesListUpdated ( object ? sender , JsonElement payload )
250+ {
251+ if ( sender != _client )
252+ return ;
253+
254+ if ( payload . TryGetProperty ( "agentId" , out var agentIdEl ) &&
255+ ! string . Equals ( agentIdEl . GetString ( ) , _agentId , StringComparison . OrdinalIgnoreCase ) )
256+ {
257+ return ;
258+ }
259+
260+ _completion . TrySetResult ( payload . Clone ( ) ) ;
261+ }
262+ }
263+
97264 private sealed class RunCompletionObserver : IDisposable
98265 {
99266 private readonly IOperatorGatewayClient _client ;
0 commit comments