gemini-computer-use 0.2.3 → 0.4.1

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 (41) hide show
  1. package/README.md +935 -148
  2. package/bin/cli.js +112 -17
  3. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  4. package/core/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
  6. package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
  7. package/core/__pycache__/schemas.cpython-313.pyc +0 -0
  8. package/core/__pycache__/server.cpython-313.pyc +0 -0
  9. package/core/__pycache__/vitals.cpython-313.pyc +0 -0
  10. package/core/__pycache__/web_share.cpython-313.pyc +0 -0
  11. package/core/agent.py +688 -27
  12. package/core/agent_harness.py +9 -2
  13. package/core/code_cache.py +15 -3
  14. package/core/device.py +2400 -0
  15. package/core/domain.py +303 -0
  16. package/core/mcp_tools.py +1949 -110
  17. package/core/policies.py +35 -5
  18. package/core/server.py +78 -32
  19. package/core/termux.py +916 -0
  20. package/core/updater.py +1713 -0
  21. package/core/version.py +31 -0
  22. package/core/vitals.py +66 -2
  23. package/core/web_share.py +814 -0
  24. package/gateway.py +1469 -98
  25. package/install-gui.cmd +40 -0
  26. package/install-gui.ps1 +1882 -0
  27. package/install-gui.strings.json +211 -0
  28. package/install.ps1 +960 -92
  29. package/install.sh +1214 -81
  30. package/ops/doctor.ps1 +735 -0
  31. package/ops/mesh-domain.sh +86 -0
  32. package/ops/nginx/README.md +77 -0
  33. package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
  34. package/ops/nginx/mesh-domain.vhost.template +58 -0
  35. package/ops/nginx/render-domain.sh +123 -0
  36. package/ops/update.ps1 +373 -0
  37. package/ops/windows/README.md +298 -0
  38. package/ops/windows/agent-watchdog.ps1 +575 -0
  39. package/ops/windows/run-hidden.vbs +64 -0
  40. package/package.json +6 -2
  41. package/skills/orchestrator.md +12 -0
package/ops/doctor.ps1 ADDED
@@ -0,0 +1,735 @@
1
+ # ==============================================================================
2
+ # Antigravity Mesh - Windows node doctor (read-only diagnostic)
3
+ #
4
+ # Answers "why is this node offline?" without changing anything on the machine:
5
+ # it only reads files, probes the pinned interpreter, asks the task scheduler and
6
+ # calls the gateway's public /health endpoint.
7
+ #
8
+ # It exists because of one real incident: after a Windows Update reboot the
9
+ # autostart silently did nothing. The interpreter pinned in the Startup .vbs was
10
+ # the Microsoft Store App Execution Alias <...>\WindowsApps\python.exe, whose only
11
+ # trace was a truncated "Python " line appended to agent.log WITHOUT a trailing
12
+ # newline - a torn line. The node stayed offline for ~29 hours. Every signal that
13
+ # would have made that obvious in five seconds is reported below, and the torn
14
+ # line is flagged explicitly (torn-line: true).
15
+ #
16
+ # WHAT IT REPORTS
17
+ # * the interpreter taken from the Startup .vbs, its --version, an
18
+ # "import websockets" probe, and whether it is the Store alias;
19
+ # * the .vbs path and its content, with the token masked as ****;
20
+ # * the Scheduled Task "AntigravityMeshWatchdog" (state, action, last run,
21
+ # last result) whenever the ScheduledTasks cmdlets are available;
22
+ # * agent.env with the token masked;
23
+ # * the heartbeat JSON and its age in seconds;
24
+ # * the last -TailLines lines of agent.log, plus torn-line: true when the log
25
+ # does not end with a newline;
26
+ # * the GET https://<gateway>/health?user=<node> JSON;
27
+ # * whether a VERIFIED agent process is running: a python.exe/pythonw.exe/py.exe
28
+ # running "-m core.agent", or the cmd.exe launcher wrapper that also carries
29
+ # the agent.log redirect. A process that merely mentions core.agent (a
30
+ # PowerShell session, a grep) is never reported as the agent;
31
+ # * the verdict lines interpreter_ok, autostart_ok, heartbeat_fresh,
32
+ # node_online.
33
+ #
34
+ # EXIT CODES
35
+ # 0 healthy: interpreter_ok, autostart_ok and node_online are all true
36
+ # 2 anything else (the verdict lines say which part failed)
37
+ #
38
+ # This file is deliberately ASCII-only: Windows PowerShell 5.1 reads a BOM-less
39
+ # script with the ANSI code page, so non-ASCII text here is a portability bug.
40
+ #
41
+ # Usage:
42
+ # .\doctor.ps1
43
+ # .\doctor.ps1 -Json
44
+ # .\doctor.ps1 -ConfigDir "$env:USERPROFILE\.config\antigravity-mesh" -TailLines 50
45
+ # ==============================================================================
46
+ [CmdletBinding()]
47
+ param(
48
+ [string]$ConfigDir = '',
49
+ [switch]$Json,
50
+ [int]$TailLines = 20
51
+ )
52
+
53
+ $ErrorActionPreference = 'Continue'
54
+
55
+ $AsJson = [bool]$Json
56
+ $TaskName = 'AntigravityMeshWatchdog'
57
+ # The watchdog treats a heartbeat as fresh within its -GraceSeconds (60 s by
58
+ # default) and runs every 5 minutes. The doctor has no -GraceSeconds parameter, so
59
+ # it is deliberately more generous: a heartbeat younger than this proves the agent
60
+ # is alive right now, and the exact age is always reported next to it.
61
+ $HeartbeatFreshSeconds = 180
62
+ $ProbeTimeoutMs = 20000
63
+
64
+ function Get-TextFile {
65
+ param([string]$Path)
66
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
67
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
68
+ return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
69
+ }
70
+ if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
71
+ return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
72
+ }
73
+ if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
74
+ return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
75
+ }
76
+ $hasNul = $false
77
+ $limit = [Math]::Min($bytes.Length, 512)
78
+ for ($i = 0; $i -lt $limit; $i++) {
79
+ if ($bytes[$i] -eq 0) { $hasNul = $true; break }
80
+ }
81
+ if ($hasNul) { return [System.Text.Encoding]::Unicode.GetString($bytes) }
82
+ return [System.Text.Encoding]::UTF8.GetString($bytes)
83
+ }
84
+
85
+ function Get-EnvMap {
86
+ param([string]$Path)
87
+ $values = @{}
88
+ try { $lines = @(Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop) } catch { return $values }
89
+ foreach ($line in $lines) {
90
+ if ($null -eq $line) { continue }
91
+ $text = ([string]$line).Trim()
92
+ if ($text.Length -eq 0) { continue }
93
+ if ($text.StartsWith('#')) { continue }
94
+ $index = $text.IndexOf('=')
95
+ if ($index -lt 1) { continue }
96
+ $values[$text.Substring(0, $index).Trim()] = $text.Substring($index + 1).Trim().Trim('"').Trim("'")
97
+ }
98
+ return $values
99
+ }
100
+
101
+ # Nothing that a human can read leaves this script without passing through here:
102
+ # the literal secret (when long enough to be unambiguous) and both shipped shapes
103
+ # of the token line are replaced with ****.
104
+ function Hide-Secret {
105
+ param([string]$Text, [string]$Secret)
106
+ if ([string]::IsNullOrEmpty($Text)) { return $Text }
107
+ $result = $Text
108
+ if ($Secret -and $Secret.Length -ge 4) { $result = $result.Replace($Secret, '****') }
109
+ $result = [regex]::Replace($result, '(?im)^(\s*MESH_TOKEN\s*=\s*).*$', '${1}****')
110
+ $result = [regex]::Replace($result, '(?im)(MESH_TOKEN"\)\s*=\s*").*?(")', '${1}****${2}')
111
+ return $result
112
+ }
113
+
114
+ function Get-LauncherInfo {
115
+ param([string]$VbsPath)
116
+ $info = @{ Present = $false; Text = ''; Interpreter = ''; PayloadDir = ''; Error = '' }
117
+ if (-not (Test-Path -LiteralPath $VbsPath -PathType Leaf)) { $info.Error = 'not found'; return $info }
118
+ $info.Present = $true
119
+ try {
120
+ $info.Text = Get-TextFile $VbsPath
121
+ } catch {
122
+ $info.Error = $_.Exception.Message
123
+ return $info
124
+ }
125
+ # The pattern stops at the line boundary ([^\r\n]*, no $ anchor: a .vbs written
126
+ # by Out-File has CRLF endings and .NET's multiline $ refuses to match before
127
+ # the \r of a CRLF pair).
128
+ $run = [regex]::Match($info.Text, '(?im)^[^\r\n]*WshShell\.Run[^\r\n]*?cmd[ \t]+/c[ \t]+"+(?<exe>[^"]+?)"+[ \t]+(?:-u[ \t]+)?-m[ \t]+core\.agent[^\r\n]*')
129
+ if (-not $run.Success) {
130
+ $run = [regex]::Match($info.Text, '(?im)^[^\r\n]*WshShell\.Run[^\r\n]*?(?<exe>[A-Za-z]:\\[^"]*?\.(?:exe|cmd|bat))')
131
+ }
132
+ if (-not $run.Success) {
133
+ $run = [regex]::Match($info.Text, '(?im)^[^\r\n]*WshShell\.Run[^\r\n]*?cmd[ \t]+/c[ \t]+"+(?<exe>[^"]+?)"+')
134
+ }
135
+ if ($run.Success) { $info.Interpreter = $run.Groups['exe'].Value.Trim() }
136
+ $dir = [regex]::Match($info.Text, '(?im)^[^\r\n]*WshShell\.CurrentDirectory[ \t]*=[ \t]*"(?<dir>[^"]+)"')
137
+ if ($dir.Success) { $info.PayloadDir = $dir.Groups['dir'].Value.Trim() }
138
+ return $info
139
+ }
140
+
141
+ function ConvertTo-UtcDateTime {
142
+ param($Value)
143
+ if ($null -eq $Value) { return $null }
144
+ if ($Value -is [datetime]) {
145
+ $stamp = [datetime]$Value
146
+ if ($stamp.Kind -eq [System.DateTimeKind]::Utc) { return $stamp }
147
+ if ($stamp.Kind -eq [System.DateTimeKind]::Local) { return $stamp.ToUniversalTime() }
148
+ return [System.DateTime]::SpecifyKind($stamp, [System.DateTimeKind]::Utc)
149
+ }
150
+ $text = ([string]$Value).Trim().Trim('"')
151
+ if ($text.Length -eq 0) { return $null }
152
+ $epoch = New-Object System.DateTime -ArgumentList @(1970, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)
153
+ $number = 0.0
154
+ $isNumber = $false
155
+ if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal] -or $Value -is [int] -or $Value -is [long]) {
156
+ $number = [double]$Value
157
+ $isNumber = $true
158
+ } elseif ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$number)) {
159
+ $isNumber = $true
160
+ }
161
+ # core/agent.py writes "ts" as a Unix timestamp in seconds, often fractional.
162
+ # Numbers are read with the invariant culture on purpose: on a comma-decimal
163
+ # locale "1767225600.25" would otherwise fail to parse and a live node would be
164
+ # declared dead.
165
+ if ($isNumber) {
166
+ # Unix seconds (10 digits) or Unix milliseconds (13 digits).
167
+ if ($number -gt 100000000000.0) { return $epoch.AddMilliseconds($number) }
168
+ if ($number -gt 100000000.0) { return $epoch.AddSeconds($number) }
169
+ return $null
170
+ }
171
+ $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal
172
+ $parsed = [datetime]::MinValue
173
+ if ([datetime]::TryParse($text, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$parsed)) {
174
+ return [System.DateTime]::SpecifyKind($parsed, [System.DateTimeKind]::Utc)
175
+ }
176
+ return $null
177
+ }
178
+
179
+ function Get-HeartbeatInfo {
180
+ param([string]$Path, [int]$FreshSeconds)
181
+ $info = @{ Present = $false; Parsed = $false; Json = ''; AgeSeconds = $null; Fresh = $false; Detail = 'missing' }
182
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $info }
183
+ $info.Present = $true
184
+ try {
185
+ $raw = [System.IO.File]::ReadAllText($Path)
186
+ } catch {
187
+ $info.Detail = 'unreadable: ' + $_.Exception.Message
188
+ return $info
189
+ }
190
+ $info.Json = $raw.Trim()
191
+ try {
192
+ $parsed = $raw | ConvertFrom-Json
193
+ } catch {
194
+ $info.Detail = 'not valid JSON'
195
+ return $info
196
+ }
197
+ $info.Parsed = $true
198
+ $stampRaw = $null
199
+ foreach ($name in @('ts', 'timestamp', 'time', 'last_seen', 'checked_at')) {
200
+ $prop = $parsed.PSObject.Properties[$name]
201
+ if ($prop -and $null -ne $prop.Value -and ([string]$prop.Value).Trim().Length -gt 0) {
202
+ $stampRaw = $prop.Value
203
+ break
204
+ }
205
+ }
206
+ if ($null -eq $stampRaw) { $info.Detail = 'JSON has no ts field'; return $info }
207
+ $stamp = ConvertTo-UtcDateTime $stampRaw
208
+ if ($null -eq $stamp) { $info.Detail = 'ts is not a timestamp: ' + ([string]$stampRaw); return $info }
209
+ $age = ((Get-Date).ToUniversalTime() - $stamp.ToUniversalTime()).TotalSeconds
210
+ $info.AgeSeconds = [Math]::Round($age, 1)
211
+ if ($age -le $FreshSeconds) {
212
+ $info.Fresh = $true
213
+ $info.Detail = 'fresh (' + $info.AgeSeconds + 's old, fresh within ' + $FreshSeconds + 's)'
214
+ } else {
215
+ $info.Detail = 'stale (' + $info.AgeSeconds + 's old, fresh within ' + $FreshSeconds + 's)'
216
+ }
217
+ return $info
218
+ }
219
+
220
+ function Invoke-Captured {
221
+ # Runs a program and returns its output, bounded by a timeout: the Store alias
222
+ # can hang instead of failing, and a diagnostic must not hang with it.
223
+ param([string]$FilePath, [string]$ArgumentLine, [string]$WorkingDirectory = '', [int]$TimeoutMs = 20000)
224
+ $result = @{ Ran = $false; ExitCode = $null; TimedOut = $false; Output = ''; Error = '' }
225
+ if ([string]::IsNullOrWhiteSpace($FilePath)) { $result.Error = 'no program to run'; return $result }
226
+ if ([string]::IsNullOrWhiteSpace($WorkingDirectory) -or -not (Test-Path -LiteralPath $WorkingDirectory -PathType Container)) {
227
+ $WorkingDirectory = ''
228
+ }
229
+ try {
230
+ $startInfo = New-Object System.Diagnostics.ProcessStartInfo
231
+ $startInfo.FileName = $FilePath
232
+ $startInfo.Arguments = $ArgumentLine
233
+ $startInfo.UseShellExecute = $false
234
+ $startInfo.RedirectStandardOutput = $true
235
+ $startInfo.RedirectStandardError = $true
236
+ $startInfo.CreateNoWindow = $true
237
+ if ($WorkingDirectory) { $startInfo.WorkingDirectory = $WorkingDirectory }
238
+ $process = [System.Diagnostics.Process]::Start($startInfo)
239
+ $result.Ran = $true
240
+ $exited = $process.WaitForExit($TimeoutMs)
241
+ if (-not $exited) {
242
+ $result.TimedOut = $true
243
+ try { $process.Kill() } catch { }
244
+ $null = $process.WaitForExit(3000)
245
+ }
246
+ $stdout = $process.StandardOutput.ReadToEnd()
247
+ $stderr = $process.StandardError.ReadToEnd()
248
+ if ($exited) { $result.ExitCode = [int]$process.ExitCode }
249
+ $result.Output = (($stdout + ' ' + $stderr) -replace '\s+', ' ').Trim()
250
+ } catch {
251
+ $result.Error = $_.Exception.Message
252
+ }
253
+ return $result
254
+ }
255
+
256
+ function Get-ScheduledTaskState {
257
+ param([string]$Name)
258
+ $state = @{
259
+ CmdletsAvailable = $false; Present = $false; Name = $Name; State = ''; Actions = @()
260
+ LastRunTime = ''; LastResult = $null; LastResultMeaning = ''; NextRunTime = ''
261
+ MissedRuns = $null; Detail = ''
262
+ }
263
+ if (-not (Get-Command -Name 'Get-ScheduledTask' -ErrorAction SilentlyContinue)) {
264
+ $state.Detail = 'the ScheduledTasks cmdlets are not available on this system'
265
+ return $state
266
+ }
267
+ $state.CmdletsAvailable = $true
268
+ try {
269
+ $task = Get-ScheduledTask -TaskName $Name -ErrorAction Stop
270
+ } catch {
271
+ $state.Detail = 'not registered'
272
+ return $state
273
+ }
274
+ $state.Present = $true
275
+ $state.State = [string]$task.State
276
+ $actions = @()
277
+ foreach ($action in @($task.Actions)) {
278
+ $text = ([string]$action.Execute).Trim()
279
+ if ($action.Arguments) { $text = ($text + ' ' + [string]$action.Arguments).Trim() }
280
+ if ($text.Length -gt 0) { $actions += $text }
281
+ }
282
+ $state.Actions = $actions
283
+ try {
284
+ $info = Get-ScheduledTaskInfo -TaskName $Name -ErrorAction Stop
285
+ if ($info) {
286
+ if ($info.LastRunTime) { $state.LastRunTime = ([datetime]$info.LastRunTime).ToString('o') }
287
+ if ($null -ne $info.LastTaskResult) { $state.LastResult = [int64]$info.LastTaskResult }
288
+ if ($info.NextRunTime) { $state.NextRunTime = ([datetime]$info.NextRunTime).ToString('o') }
289
+ if ($null -ne $info.NumberOfMissedRuns) { $state.MissedRuns = [int64]$info.NumberOfMissedRuns }
290
+ }
291
+ } catch {
292
+ $state.Detail = 'registered, but its run information is unreadable: ' + $_.Exception.Message
293
+ }
294
+ $meanings = @{
295
+ 0 = 'ok'
296
+ 2 = 'the watchdog refused the configuration (agent.env, the .vbs or the interpreter)'
297
+ 3 = 'the watchdog tried to recover the node and it was still offline'
298
+ 267009 = 'the task is currently running'
299
+ 267011 = 'the task has not run yet'
300
+ 2147942401 = 'access denied (the task may require elevation)'
301
+ }
302
+ if ($null -ne $state.LastResult) {
303
+ if ($meanings.ContainsKey([int]$state.LastResult)) { $state.LastResultMeaning = $meanings[[int]$state.LastResult] }
304
+ else { $state.LastResultMeaning = 'unrecognised result code' }
305
+ }
306
+ return $state
307
+ }
308
+
309
+ function Test-AgentProcess {
310
+ # The command line ALONE is not evidence. A PowerShell session that merely
311
+ # mentions "core.agent" (a grep, a script that prints the module name) matches
312
+ # the bare substring, so a dead node would look alive and would never be
313
+ # restarted. The process must be a Python interpreter running the module, or
314
+ # the cmd.exe wrapper the logon launcher uses - which also carries the
315
+ # agent.log append redirect. PowerShell, wscript and everything else never
316
+ # qualifies, whatever its command line says.
317
+ param([string]$Name, [string]$CommandLine)
318
+ if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $false }
319
+ $processName = ''
320
+ if ($Name) { $processName = ([string]$Name).Trim().ToLowerInvariant() }
321
+ foreach ($shell in @('powershell', 'pwsh', 'wscript', 'cscript')) {
322
+ if ($processName.StartsWith($shell)) { return $false }
323
+ }
324
+ # "-m core.agent" has to be a real argument, so it must open the command line
325
+ # or follow a space or a quote.
326
+ if ($CommandLine -notmatch '(?i)(^|[\s"])-m[\s"]+core\.agent\b') { return $false }
327
+ if ($processName -eq 'py.exe') { return $true }
328
+ if ($processName.StartsWith('python')) { return $true }
329
+ if ($processName -eq 'cmd.exe' -or $processName -eq 'cmd') {
330
+ # Launcher wrapper: cmd /c ""<python>" -u -m core.agent >> "<agent.log>" 2>&1"
331
+ return [bool]($CommandLine -match '(?i)agent\.log')
332
+ }
333
+ return $false
334
+ }
335
+
336
+ function Get-AgentProcesses {
337
+ # Read-only, and deliberately the SAME rule as the watchdog's last liveness
338
+ # check, so a reader can tell "the agent is dead" from "the gateway cannot see
339
+ # it" without a different answer from the two scripts.
340
+ $found = @()
341
+ $procs = @()
342
+ try {
343
+ $procs = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop)
344
+ } catch {
345
+ try { $procs = @(Get-WmiObject -Class Win32_Process -ErrorAction Stop) } catch { $procs = @() }
346
+ }
347
+ foreach ($proc in $procs) {
348
+ $commandLine = ''
349
+ $prop = $proc.PSObject.Properties['CommandLine']
350
+ if ($prop -and $prop.Value) { $commandLine = [string]$prop.Value }
351
+ if (-not (Test-AgentProcess -Name ([string]$proc.Name) -CommandLine $commandLine)) { continue }
352
+ $found += @{ Pid = [int]$proc.ProcessId; Name = [string]$proc.Name; CommandLine = $commandLine }
353
+ }
354
+ return $found
355
+ }
356
+
357
+ function Get-LastByte {
358
+ param([string]$Path)
359
+ try {
360
+ $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
361
+ try {
362
+ if ($stream.Length -eq 0) { return $null }
363
+ $null = $stream.Seek(-1, [System.IO.SeekOrigin]::End)
364
+ return $stream.ReadByte()
365
+ } finally { $stream.Dispose() }
366
+ } catch {
367
+ return $null
368
+ }
369
+ }
370
+
371
+ function Get-LogInfo {
372
+ param([string]$Path, [int]$Lines)
373
+ $info = @{ Present = $false; Bytes = 0; LastWriteTime = ''; Tail = @(); TornLine = $false; LastByte = $null; Error = '' }
374
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $info }
375
+ $info.Present = $true
376
+ try {
377
+ $item = Get-Item -LiteralPath $Path -ErrorAction Stop
378
+ $info.Bytes = [int64]$item.Length
379
+ $info.LastWriteTime = ([datetime]$item.LastWriteTimeUtc).ToString('o')
380
+ } catch {
381
+ $info.Error = $_.Exception.Message
382
+ }
383
+ if ($Lines -gt 0) {
384
+ try {
385
+ $info.Tail = @(Get-Content -LiteralPath $Path -Tail $Lines -Encoding UTF8 -ErrorAction Stop)
386
+ } catch {
387
+ $info.Error = $_.Exception.Message
388
+ }
389
+ }
390
+ # A log whose last byte is not LF ends in a torn line - the truncated
391
+ # "Python " fragment was the only trace the incident left behind.
392
+ $info.LastByte = Get-LastByte $Path
393
+ if ($null -ne $info.LastByte -and $info.LastByte -ne 0x0A) { $info.TornLine = $true }
394
+ return $info
395
+ }
396
+
397
+ function Get-GatewayInfo {
398
+ param([string]$Gateway, [string]$NodeUser, [int]$TimeoutSec = 15)
399
+ $info = @{ Url = ''; Checked = $false; Online = $false; Json = ''; Detail = 'not checked' }
400
+ if ([string]::IsNullOrWhiteSpace($Gateway) -or [string]::IsNullOrWhiteSpace($NodeUser)) {
401
+ $info.Detail = 'gateway or node name is missing from agent.env'
402
+ return $info
403
+ }
404
+ $hostName = ($Gateway.Trim() -replace '^[A-Za-z][A-Za-z0-9+.-]*://', '').Trim().TrimEnd('/')
405
+ if ($hostName.Length -eq 0) {
406
+ $info.Detail = 'the gateway value is empty after normalisation'
407
+ return $info
408
+ }
409
+ $info.Url = 'https://' + $hostName + '/health?user=' + [uri]::EscapeDataString($NodeUser)
410
+ $info.Checked = $true
411
+ try {
412
+ $response = Invoke-RestMethod -Uri $info.Url -Method Get -TimeoutSec $TimeoutSec -ErrorAction Stop
413
+ } catch {
414
+ $info.Detail = 'unreachable: ' + $_.Exception.Message
415
+ return $info
416
+ }
417
+ try { $info.Json = ($response | ConvertTo-Json -Compress -Depth 5) } catch { $info.Json = '' }
418
+ $prop = $response.PSObject.Properties['node_online']
419
+ $online = $false
420
+ if ($prop) {
421
+ if ($prop.Value -is [bool]) { $online = [bool]$prop.Value } else { $online = (([string]$prop.Value).Trim().ToLower() -eq 'true') }
422
+ }
423
+ $info.Online = $online
424
+ if ($online) { $info.Detail = 'node_online=true' } else { $info.Detail = 'node_online=false' }
425
+ return $info
426
+ }
427
+
428
+ # ==============================================================================
429
+ # Collect (read-only)
430
+ # ==============================================================================
431
+ try {
432
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
433
+ } catch { }
434
+
435
+ if ([string]::IsNullOrWhiteSpace($ConfigDir)) {
436
+ $profileDir = $env:USERPROFILE
437
+ if ([string]::IsNullOrWhiteSpace($profileDir)) { $profileDir = [Environment]::GetFolderPath('UserProfile') }
438
+ if ([string]::IsNullOrWhiteSpace($profileDir)) { $profileDir = (Get-Location).Path }
439
+ $ConfigDir = Join-Path $profileDir '.config\antigravity-mesh'
440
+ }
441
+ $envFile = Join-Path $ConfigDir 'agent.env'
442
+ $settings = Get-EnvMap $envFile
443
+ $gateway = ''
444
+ $nodeUser = ''
445
+ $token = ''
446
+ if ($settings.ContainsKey('MESH_GATEWAY')) { $gateway = [string]$settings['MESH_GATEWAY'] }
447
+ if ($settings.ContainsKey('MESH_USER')) { $nodeUser = [string]$settings['MESH_USER'] }
448
+ if ($settings.ContainsKey('MESH_TOKEN')) { $token = [string]$settings['MESH_TOKEN'] }
449
+
450
+ $missingKeys = @()
451
+ foreach ($key in @('MESH_GATEWAY', 'MESH_USER', 'MESH_TOKEN')) {
452
+ if (-not $settings.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($settings[$key])) { $missingKeys += $key }
453
+ }
454
+
455
+ $envPresent = Test-Path -LiteralPath $envFile -PathType Leaf
456
+ $envRaw = ''
457
+ if ($envPresent) {
458
+ try { $envRaw = Get-TextFile $envFile } catch { $envRaw = 'unreadable: ' + $_.Exception.Message }
459
+ }
460
+ $envMasked = Hide-Secret -Text $envRaw -Secret $token
461
+
462
+ # --- launcher -----------------------------------------------------------------
463
+ $startupDir = [Environment]::GetFolderPath('Startup')
464
+ $vbsPath = ''
465
+ if ([string]::IsNullOrWhiteSpace($startupDir)) { $vbsPath = 'antigravity-agent.vbs (the Startup folder could not be resolved)' }
466
+ else { $vbsPath = Join-Path $startupDir 'antigravity-agent.vbs' }
467
+ $launcher = Get-LauncherInfo $vbsPath
468
+ $interpreter = $launcher.Interpreter
469
+ $storeAlias = [bool]($interpreter -and ($interpreter -match '(?i)\\WindowsApps\\'))
470
+ $interpreterExists = [bool]($interpreter -and (Test-Path -LiteralPath $interpreter -PathType Leaf))
471
+
472
+ $payloadDir = $launcher.PayloadDir
473
+ $payloadProblem = ''
474
+ if ([string]::IsNullOrWhiteSpace($payloadDir)) {
475
+ $payloadProblem = 'WshShell.CurrentDirectory is missing from the launcher'
476
+ } elseif (-not (Test-Path -LiteralPath $payloadDir -PathType Container)) {
477
+ $payloadProblem = 'WshShell.CurrentDirectory is not a directory: ' + $payloadDir
478
+ }
479
+ $payloadOk = [bool]($payloadProblem -eq '')
480
+ $coreModule = ''
481
+ if ($payloadOk) {
482
+ $coreModule = Join-Path $payloadDir 'core\agent.py'
483
+ }
484
+ $coreModuleOk = [bool]($coreModule -and (Test-Path -LiteralPath $coreModule -PathType Leaf))
485
+
486
+ # --- interpreter probes -------------------------------------------------------
487
+ $versionProbe = Invoke-Captured -FilePath $interpreter -ArgumentLine '--version' -WorkingDirectory $payloadDir -TimeoutMs $ProbeTimeoutMs
488
+ $websocketsProbe = Invoke-Captured -FilePath $interpreter -ArgumentLine '-c "import websockets"' -WorkingDirectory $payloadDir -TimeoutMs $ProbeTimeoutMs
489
+ $versionOk = [bool]($interpreterExists -and $versionProbe.Ran -and (-not $versionProbe.TimedOut) -and ($versionProbe.ExitCode -eq 0) -and ($versionProbe.Output -match '(?i)^Python\s+\d'))
490
+ $websocketsOk = [bool]($interpreterExists -and $websocketsProbe.Ran -and (-not $websocketsProbe.TimedOut) -and ($websocketsProbe.ExitCode -eq 0))
491
+
492
+ $interpreterOk = [bool]($interpreter -and (-not $storeAlias) -and $interpreterExists -and $versionOk)
493
+ $autostartOk = [bool]($launcher.Present -and $interpreter -and (-not $storeAlias) -and $interpreterExists -and $payloadOk)
494
+
495
+ # --- heartbeat / log / task / gateway ----------------------------------------
496
+ $heartbeatPath = Join-Path $ConfigDir 'agent.heartbeat'
497
+ $heartbeat = Get-HeartbeatInfo -Path $heartbeatPath -FreshSeconds $HeartbeatFreshSeconds
498
+ $agentLogPath = Join-Path $ConfigDir 'agent.log'
499
+ $logLines = [Math]::Max(0, $TailLines)
500
+ $log = Get-LogInfo -Path $agentLogPath -Lines $logLines
501
+ $task = Get-ScheduledTaskState -Name $TaskName
502
+ $processes = @(Get-AgentProcesses)
503
+ $health = Get-GatewayInfo -Gateway $gateway -NodeUser $nodeUser
504
+
505
+ $healthy = [bool]($interpreterOk -and $autostartOk -and $health.Online)
506
+
507
+ $problems = @()
508
+ if (-not $interpreterOk) {
509
+ if (-not $interpreter) { $problems += 'no interpreter could be read from the launcher' }
510
+ elseif ($storeAlias) { $problems += 'the pinned interpreter is the Microsoft Store alias (it never runs Python)' }
511
+ elseif (-not $interpreterExists) { $problems += 'the pinned interpreter does not exist' }
512
+ elseif (-not $versionOk) { $problems += ('the pinned interpreter does not answer --version' + $(if ($versionProbe.TimedOut) { ' (it timed out)' } else { '' })) }
513
+ }
514
+ if (-not $autostartOk) {
515
+ if (-not $launcher.Present) { $problems += 'the Startup launcher is missing: ' + $vbsPath }
516
+ if (-not $payloadOk) { $problems += $payloadProblem }
517
+ }
518
+ if ($autostartOk -and -not $coreModuleOk) { $problems += 'core\agent.py is not in the payload directory: ' + $coreModule }
519
+ if ($log.TornLine) { $problems += 'agent.log ends in a torn line (a truncated write, exactly like the post-reboot "Python " failure)' }
520
+ if (-not $health.Online) { $problems += 'the gateway does not report this node as online: ' + $health.Detail }
521
+ if ($processes.Count -eq 0) { $problems += 'no agent process found: nothing is running python -m core.agent (the process list may also be unreadable from this session)' }
522
+
523
+ # ==============================================================================
524
+ # Report
525
+ # ==============================================================================
526
+ $report = [ordered]@{
527
+ generated_at = ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + 'Z')
528
+ config_dir = $ConfigDir
529
+ launcher = [ordered]@{
530
+ path = $vbsPath
531
+ present = $launcher.Present
532
+ read_error = $launcher.Error
533
+ interpreter = $interpreter
534
+ payload_dir = $payloadDir
535
+ payload_ok = $payloadOk
536
+ payload_problem = $payloadProblem
537
+ core_module = $coreModule
538
+ core_module_present = $coreModuleOk
539
+ content = (Hide-Secret -Text $launcher.Text -Secret $token)
540
+ }
541
+ interpreter = [ordered]@{
542
+ path = $interpreter
543
+ exists = $interpreterExists
544
+ store_alias = $storeAlias
545
+ version = $versionProbe.Output
546
+ version_exit_code = $versionProbe.ExitCode
547
+ version_timed_out = $versionProbe.TimedOut
548
+ version_ok = $versionOk
549
+ websockets_ok = $websocketsOk
550
+ websockets_output = $websocketsProbe.Output
551
+ probe_error = ($versionProbe.Error + ' ' + $websocketsProbe.Error).Trim()
552
+ probe_working_directory = $payloadDir
553
+ }
554
+ scheduled_task = [ordered]@{
555
+ name = $task.Name
556
+ cmdlets_available = $task.CmdletsAvailable
557
+ present = $task.Present
558
+ state = $task.State
559
+ actions = $task.Actions
560
+ last_run_time = $task.LastRunTime
561
+ last_result = $task.LastResult
562
+ last_result_meaning = $task.LastResultMeaning
563
+ next_run_time = $task.NextRunTime
564
+ missed_runs = $task.MissedRuns
565
+ detail = $task.Detail
566
+ }
567
+ agent_env = [ordered]@{
568
+ path = $envFile
569
+ present = $envPresent
570
+ gateway = $gateway
571
+ user = $nodeUser
572
+ token = '****'
573
+ missing_keys = $missingKeys
574
+ content = $envMasked
575
+ }
576
+ heartbeat = [ordered]@{
577
+ path = $heartbeatPath
578
+ present = $heartbeat.Present
579
+ parsed = $heartbeat.Parsed
580
+ json = $heartbeat.Json
581
+ age_seconds = $heartbeat.AgeSeconds
582
+ fresh = $heartbeat.Fresh
583
+ fresh_within_seconds = $HeartbeatFreshSeconds
584
+ detail = $heartbeat.Detail
585
+ }
586
+ agent_log = [ordered]@{
587
+ path = $agentLogPath
588
+ present = $log.Present
589
+ bytes = $log.Bytes
590
+ last_write_utc = $log.LastWriteTime
591
+ tail_lines = $logLines
592
+ lines = $log.Tail
593
+ torn_line = $log.TornLine
594
+ last_byte = $log.LastByte
595
+ read_error = $log.Error
596
+ }
597
+ agent_processes = @($processes | ForEach-Object { [ordered]@{ pid = $_.Pid; name = $_.Name; command_line = $_.CommandLine } })
598
+ gateway = [ordered]@{
599
+ url = $health.Url
600
+ checked = $health.Checked
601
+ node_online = $health.Online
602
+ json = $health.Json
603
+ detail = $health.Detail
604
+ }
605
+ problems = $problems
606
+ verdict = [ordered]@{
607
+ interpreter_ok = $interpreterOk
608
+ autostart_ok = $autostartOk
609
+ heartbeat_fresh = $heartbeat.Fresh
610
+ node_online = $health.Online
611
+ }
612
+ }
613
+
614
+ if ($AsJson) {
615
+ # Exactly one JSON object, nothing else: the exit code carries the verdict, and
616
+ # ConvertTo-Json escapes any non-ASCII log text, so this stays 7-bit clean.
617
+ $report | ConvertTo-Json -Depth 8
618
+ } else {
619
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
620
+
621
+ $out = New-Object System.Collections.ArrayList
622
+ function Add-Out {
623
+ param([string]$Text)
624
+ [void]$out.Add($Text)
625
+ }
626
+ function Add-Field {
627
+ param([string]$Name, $Value)
628
+ # Booleans print as lowercase true/false so the verdict lines can be read by
629
+ # eye and by grep, exactly as documented.
630
+ if ($Value -is [bool]) {
631
+ $text = 'false'
632
+ if ($Value) { $text = 'true' }
633
+ } else {
634
+ $text = ''
635
+ if ($null -ne $Value) { $text = [string]$Value }
636
+ }
637
+ [void]$out.Add(('{0,-22} : {1}' -f $Name, $text))
638
+ }
639
+ function Add-Block {
640
+ param([string]$Name, [string[]]$Lines)
641
+ Add-Out ($Name + ':')
642
+ if (-not $Lines -or $Lines.Count -eq 0) { Add-Out ' (empty)'; return }
643
+ foreach ($line in $Lines) { Add-Out (' ' + $line) }
644
+ }
645
+
646
+ Add-Out 'Antigravity Mesh doctor (read-only)'
647
+ Add-Out '================================='
648
+ Add-Field 'generated_at' $report.generated_at
649
+ Add-Field 'config_dir' $ConfigDir
650
+ Add-Out ''
651
+ Add-Out '[launcher - Windows Startup .vbs]'
652
+ Add-Field 'vbs_path' $vbsPath
653
+ Add-Field 'vbs_present' $launcher.Present
654
+ if ($launcher.Error) { Add-Field -Name 'vbs_read_error' -Value $launcher.Error }
655
+ Add-Field 'vbs_interpreter' $interpreter
656
+ Add-Field 'payload_dir' $payloadDir
657
+ Add-Field 'payload_ok' $payloadOk
658
+ if ($payloadProblem) { Add-Field -Name 'payload_problem' -Value $payloadProblem }
659
+ Add-Field 'core_module_present' $coreModuleOk
660
+ if ($report.launcher.content) { Add-Block 'vbs_content (token masked)' @($report.launcher.content -split "`r?`n") }
661
+ Add-Out ''
662
+ Add-Out '[interpreter]'
663
+ Add-Field 'interpreter_exists' $interpreterExists
664
+ Add-Field 'store_alias' $storeAlias
665
+ Add-Field -Name 'interpreter_version' -Value $versionProbe.Output
666
+ Add-Field 'version_exit_code' $versionProbe.ExitCode
667
+ Add-Field 'version_timed_out' $versionProbe.TimedOut
668
+ Add-Field 'websockets_ok' $websocketsOk
669
+ Add-Field -Name 'websockets_probe' -Value $websocketsProbe.Output
670
+ Add-Field 'probe_cwd' $payloadDir
671
+ Add-Out ''
672
+ Add-Out '[scheduled task]'
673
+ Add-Field 'task_name' $TaskName
674
+ Add-Field 'task_cmdlets_available' $task.CmdletsAvailable
675
+ Add-Field 'task_present' $task.Present
676
+ Add-Field 'task_state' $task.State
677
+ Add-Field 'task_last_run_time' $task.LastRunTime
678
+ Add-Field 'task_last_result' $task.LastResult
679
+ Add-Field 'task_last_result_meaning' $task.LastResultMeaning
680
+ Add-Field 'task_next_run_time' $task.NextRunTime
681
+ Add-Field 'task_missed_runs' $task.MissedRuns
682
+ if ($task.Detail) { Add-Field -Name 'task_detail' -Value $task.Detail }
683
+ Add-Block 'task_actions' @($task.Actions)
684
+ Add-Out ''
685
+ Add-Out '[agent.env]'
686
+ Add-Field 'agent_env_path' $envFile
687
+ Add-Field 'agent_env_present' $envPresent
688
+ Add-Field 'MESH_GATEWAY' $gateway
689
+ Add-Field 'MESH_USER' $nodeUser
690
+ Add-Field 'MESH_TOKEN' '****'
691
+ if ($missingKeys.Count -gt 0) { Add-Field 'missing_keys' ($missingKeys -join ', ') }
692
+ if ($envMasked) { Add-Block 'agent_env_content (token masked)' @($envMasked -split "`r?`n") }
693
+ Add-Out ''
694
+ Add-Out '[heartbeat]'
695
+ Add-Field 'heartbeat_path' $heartbeatPath
696
+ Add-Field 'heartbeat_present' $heartbeat.Present
697
+ Add-Field 'heartbeat_parsed' $heartbeat.Parsed
698
+ Add-Field 'heartbeat_age_seconds' $heartbeat.AgeSeconds
699
+ Add-Field 'heartbeat_fresh' $heartbeat.Fresh
700
+ Add-Field 'heartbeat_detail' $heartbeat.Detail
701
+ if ($heartbeat.Json) { Add-Block 'heartbeat_json' @($heartbeat.Json -split "`r?`n") }
702
+ Add-Out ''
703
+ Add-Out '[agent.log]'
704
+ Add-Field 'agent_log_path' $agentLogPath
705
+ Add-Field 'agent_log_present' $log.Present
706
+ Add-Field 'agent_log_bytes' $log.Bytes
707
+ Add-Field 'agent_log_last_write_utc' $log.LastWriteTime
708
+ Add-Field 'torn-line' $log.TornLine
709
+ if ($log.Error) { Add-Field -Name 'agent_log_read_error' -Value $log.Error }
710
+ Add-Block ('agent_log_tail (last ' + $logLines + ' lines)') @($log.Tail)
711
+ Add-Out ''
712
+ Add-Out '[agent process]'
713
+ Add-Field 'core_agent_processes' $processes.Count
714
+ foreach ($proc in $processes) { Add-Field -Name ('pid ' + $proc.Pid) -Value $proc.CommandLine }
715
+ Add-Out ''
716
+ Add-Out '[gateway health]'
717
+ Add-Field 'health_url' $health.Url
718
+ Add-Field 'health_checked' $health.Checked
719
+ Add-Field -Name 'health_detail' -Value $health.Detail
720
+ if ($health.Json) { Add-Block 'health_json' @($health.Json -split "`r?`n") }
721
+ Add-Out ''
722
+ Add-Out '[problems]'
723
+ if ($problems.Count -eq 0) { Add-Out ' (none)' } else { foreach ($problem in $problems) { Add-Out (' - ' + $problem) } }
724
+ Add-Out ''
725
+ Add-Out '[verdict]'
726
+ Add-Field 'interpreter_ok' $interpreterOk
727
+ Add-Field 'autostart_ok' $autostartOk
728
+ Add-Field 'heartbeat_fresh' $heartbeat.Fresh
729
+ Add-Field 'node_online' $health.Online
730
+
731
+ foreach ($line in $out) { Write-Host $line }
732
+ }
733
+
734
+ if ($healthy) { exit 0 }
735
+ exit 2