@blunking/codexlink 0.1.2 → 0.1.10

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.
@@ -0,0 +1,454 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [int]$FrontendPid,
4
+
5
+ [int]$AttachPid = 0,
6
+
7
+ [Parameter(Mandatory = $true)]
8
+ [string]$RuntimeFile,
9
+
10
+ [Parameter(Mandatory = $true)]
11
+ [string]$StateFile,
12
+
13
+ [Parameter(Mandatory = $true)]
14
+ [string]$BaseTitle,
15
+
16
+ [string]$LogFile = ""
17
+ )
18
+
19
+ $ErrorActionPreference = "SilentlyContinue"
20
+
21
+ if (-not ("CodexLink.NativeMethods" -as [type])) {
22
+ Add-Type -TypeDefinition @"
23
+ using System;
24
+ using System.Runtime.InteropServices;
25
+
26
+ namespace CodexLink
27
+ {
28
+ public static class NativeMethods
29
+ {
30
+ [DllImport("kernel32.dll", SetLastError = true)]
31
+ public static extern bool FreeConsole();
32
+
33
+ [DllImport("kernel32.dll", SetLastError = true)]
34
+ public static extern bool AttachConsole(uint dwProcessId);
35
+
36
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "SetConsoleTitleW")]
37
+ public static extern bool SetConsoleTitle(string lpConsoleTitle);
38
+
39
+ [DllImport("kernel32.dll", SetLastError = true)]
40
+ public static extern IntPtr GetStdHandle(int nStdHandle);
41
+
42
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "WriteConsoleW")]
43
+ public static extern bool WriteConsole(IntPtr hConsoleOutput, string lpBuffer, uint nNumberOfCharsToWrite, out uint lpNumberOfCharsWritten, IntPtr lpReserved);
44
+ }
45
+ }
46
+ "@
47
+ }
48
+
49
+ function Try-ReadJson {
50
+ param([string]$Path)
51
+ if (-not (Test-Path $Path)) { return $null }
52
+ try {
53
+ $raw = Get-Content -Raw -Path $Path
54
+ if ($null -eq $raw) { return $null }
55
+ return ($raw -replace "^\uFEFF", "") | ConvertFrom-Json
56
+ } catch {
57
+ return $null
58
+ }
59
+ }
60
+
61
+ function Write-WatcherLog {
62
+ param([string]$Message)
63
+ if (-not $LogFile) { return }
64
+ try {
65
+ Add-Content -Path $LogFile -Value (((Get-Date).ToUniversalTime().ToString("o")) + " " + $Message) -Encoding UTF8
66
+ } catch {
67
+ }
68
+ }
69
+
70
+ function Test-PidAlive {
71
+ param([int]$ProcId)
72
+ if ($ProcId -le 0) { return $false }
73
+ return $null -ne (Get-Process -Id $ProcId -ErrorAction SilentlyContinue)
74
+ }
75
+
76
+ function Get-PidProcessName {
77
+ param([int]$ProcId)
78
+ try {
79
+ return [string](Get-Process -Id $ProcId -ErrorAction Stop).ProcessName
80
+ } catch {
81
+ return ""
82
+ }
83
+ }
84
+
85
+ function Get-EffectiveAttachPid {
86
+ if ($AttachPid -gt 0 -and (Test-PidAlive -ProcId $AttachPid) -and (Get-PidProcessName -ProcId $AttachPid) -ine "conhost") {
87
+ return $AttachPid
88
+ }
89
+
90
+ if (Test-PidAlive -ProcId $FrontendPid) {
91
+ return $FrontendPid
92
+ }
93
+
94
+ try {
95
+ $child = Get-CimInstance Win32_Process -Filter ("ParentProcessId = " + $FrontendPid) |
96
+ Where-Object { $_.Name -ieq "node.exe" } |
97
+ Select-Object -First 1
98
+ if ($child -and [int]$child.ProcessId -gt 0) {
99
+ return [int]$child.ProcessId
100
+ }
101
+ } catch {
102
+ }
103
+
104
+ return 0
105
+ }
106
+
107
+ function Normalize-Preview {
108
+ param([string]$Value, [int]$MaxLength = 44)
109
+ $text = [string]$Value
110
+ $text = $text -replace "\s+", " "
111
+ $text = $text.Trim()
112
+ if (-not $text) { return "" }
113
+ if ($text.Length -le $MaxLength) { return $text }
114
+ return ($text.Substring(0, [Math]::Max(0, $MaxLength - 3)).TrimEnd() + "...")
115
+ }
116
+
117
+ function Get-OpenPendingReplyCount {
118
+ param([object]$State)
119
+ return @(Get-OpenPendingReplies -State $State).Count
120
+ }
121
+
122
+ function Get-OpenPendingReplies {
123
+ param([object]$State)
124
+ if ($null -eq $State -or $null -eq $State.pendingReplies) {
125
+ return @()
126
+ }
127
+ return @($State.pendingReplies | Where-Object { -not $_.sentAt -and @("error","expired","ignored_bot","suppressed_ack","superseded","sent","stale_thread") -notcontains [string]$_.status })
128
+ }
129
+
130
+ function Get-IsoAgeMs {
131
+ param([string]$IsoValue)
132
+ if (-not $IsoValue) { return [double]::PositiveInfinity }
133
+ try {
134
+ $parsed = [DateTimeOffset]::Parse($IsoValue)
135
+ return ([DateTimeOffset]::UtcNow - $parsed.ToUniversalTime()).TotalMilliseconds
136
+ } catch {
137
+ return [double]::PositiveInfinity
138
+ }
139
+ }
140
+
141
+ function Get-QueueWaitReason {
142
+ param(
143
+ [object]$State,
144
+ [int]$IdleCooldownMs = 15000
145
+ )
146
+
147
+ $pendingReplyCount = Get-OpenPendingReplyCount -State $State
148
+ if ($pendingReplyCount -gt 0) {
149
+ return "arbeitet noch"
150
+ }
151
+
152
+ $lastInjectAt = ""
153
+ try { $lastInjectAt = [string]$State.lastInjectAt } catch { $lastInjectAt = "" }
154
+ if ($lastInjectAt) {
155
+ $ageMs = Get-IsoAgeMs -IsoValue $lastInjectAt
156
+ if ($ageMs -lt $IdleCooldownMs) {
157
+ return "wartet auf Ruhe"
158
+ }
159
+ }
160
+
161
+ return "wartet in Queue"
162
+ }
163
+
164
+ function Format-QueuePreview {
165
+ param([object]$Entry, [int]$MaxLength = 44, [switch]$Pending)
166
+ if ($null -eq $Entry) { return "" }
167
+ $rawText = if ($Pending) { [string]$Entry.sourceText } else { [string]$Entry.text }
168
+ $text = Normalize-Preview -Value $rawText -MaxLength $MaxLength
169
+ if (-not $text) { return "" }
170
+ $user = [string]$Entry.user
171
+ $group = [string]$Entry.groupTitle
172
+ $prefix = if ($Pending) { "pending: " } else { "" }
173
+ if ($group) {
174
+ return Normalize-Preview -Value ("${prefix}${user}@${group}: $text") -MaxLength $MaxLength
175
+ }
176
+ if ($user) {
177
+ return Normalize-Preview -Value ("${prefix}${user}: $text") -MaxLength $MaxLength
178
+ }
179
+ return "${prefix}${text}"
180
+ }
181
+
182
+ function Get-QueueTitle {
183
+ param(
184
+ [object]$State,
185
+ [string]$FallbackTitle,
186
+ [int]$IdleCooldownMs = 15000
187
+ )
188
+
189
+ if ($null -eq $State -or $null -eq $State.queue) {
190
+ return $FallbackTitle
191
+ }
192
+
193
+ $queued = @($State.queue | Where-Object { $_.status -eq "queued" })
194
+ $pendingReplies = @(Get-OpenPendingReplies -State $State)
195
+ $totalWaiting = $queued.Count + $pendingReplies.Count
196
+ if ($totalWaiting -eq 0) {
197
+ return $FallbackTitle
198
+ }
199
+
200
+ $directCount = @($queued | Where-Object { @("direct", "lane") -contains [string]$_.relevance }).Count
201
+ $ambientCount = @($queued | Where-Object { [string]$_.relevance -eq "ambient" }).Count
202
+ $escalationCount = @($queued | Where-Object { [string]$_.relevance -eq "escalation" }).Count
203
+ $pendingCount = $pendingReplies.Count
204
+
205
+ $nextDirect = @($queued | Where-Object { @("direct", "lane", "escalation") -contains [string]$_.relevance } | Select-Object -First 1)
206
+ $nextAny = @($queued | Select-Object -First 1)
207
+ $pendingFocus = @($pendingReplies | Sort-Object @{ Expression = { [string]$_.createdAt } } | Select-Object -First 1)
208
+ $focus = if ($pendingFocus.Count -gt 0) { $pendingFocus[0] } elseif ($nextDirect.Count -gt 0) { $nextDirect[0] } elseif ($nextAny.Count -gt 0) { $nextAny[0] } else { $null }
209
+ $preview = if ($focus) { Format-QueuePreview -Entry $focus -Pending:($pendingFocus.Count -gt 0) } else { "" }
210
+
211
+ $parts = @("Q:$totalWaiting")
212
+ if ($pendingCount -gt 0) { $parts += "P:$pendingCount" }
213
+ if ($directCount -gt 0) { $parts += "D:$directCount" }
214
+ if ($ambientCount -gt 0) { $parts += "G:$ambientCount" }
215
+ if ($escalationCount -gt 0) { $parts += "E:$escalationCount" }
216
+
217
+ $summary = ($parts -join " ")
218
+ $waitReason = Get-QueueWaitReason -State $State -IdleCooldownMs $IdleCooldownMs
219
+ if ($preview) {
220
+ return "$FallbackTitle | $summary | $waitReason | $preview"
221
+ }
222
+ return "$FallbackTitle | $summary | $waitReason"
223
+ }
224
+
225
+ function Update-ConsoleTitle {
226
+ param([string]$Title)
227
+ try {
228
+ [void][CodexLink.NativeMethods]::FreeConsole()
229
+ $targetPid = Get-EffectiveAttachPid
230
+ $attached = [CodexLink.NativeMethods]::AttachConsole([uint32]$targetPid)
231
+ if (-not $attached) {
232
+ Write-WatcherLog ("WAIT attach_console_failed target=" + $targetPid)
233
+ return $false
234
+ }
235
+ $updated = [CodexLink.NativeMethods]::SetConsoleTitle($Title)
236
+ [void][CodexLink.NativeMethods]::FreeConsole()
237
+ return $updated
238
+ } catch {
239
+ return $false
240
+ }
241
+ }
242
+
243
+ function Write-ConsoleNotice {
244
+ param([string]$Notice)
245
+ if ($env:BLUN_TELEGRAM_CONSOLE_NOTICES -ne "1") {
246
+ return $false
247
+ }
248
+ try {
249
+ [void][CodexLink.NativeMethods]::FreeConsole()
250
+ $targetPid = Get-EffectiveAttachPid
251
+ $attached = [CodexLink.NativeMethods]::AttachConsole([uint32]$targetPid)
252
+ if (-not $attached) {
253
+ Write-WatcherLog ("WAIT attach_console_failed_notice target=" + $targetPid)
254
+ return $false
255
+ }
256
+ $handle = [CodexLink.NativeMethods]::GetStdHandle(-11)
257
+ if ($handle -eq [IntPtr]::Zero -or $handle -eq [IntPtr](-1)) {
258
+ [void][CodexLink.NativeMethods]::FreeConsole()
259
+ return $false
260
+ }
261
+ $line = "[CodexLink Queue] $Notice`r`n"
262
+ [uint32]$written = 0
263
+ $ok = [CodexLink.NativeMethods]::WriteConsole($handle, $line, [uint32]$line.Length, [ref]$written, [IntPtr]::Zero)
264
+ [void][CodexLink.NativeMethods]::FreeConsole()
265
+ return $ok
266
+ } catch {
267
+ return $false
268
+ }
269
+ }
270
+
271
+ function Write-ConsoleUiNotice {
272
+ param(
273
+ [string]$Kind,
274
+ [string]$Notice
275
+ )
276
+ try {
277
+ [void][CodexLink.NativeMethods]::FreeConsole()
278
+ $targetPid = Get-EffectiveAttachPid
279
+ $attached = [CodexLink.NativeMethods]::AttachConsole([uint32]$targetPid)
280
+ if (-not $attached) {
281
+ Write-WatcherLog ("WAIT attach_console_failed_ui target=" + $targetPid)
282
+ return $false
283
+ }
284
+ $handle = [CodexLink.NativeMethods]::GetStdHandle(-11)
285
+ if ($handle -eq [IntPtr]::Zero -or $handle -eq [IntPtr](-1)) {
286
+ [void][CodexLink.NativeMethods]::FreeConsole()
287
+ return $false
288
+ }
289
+ $prefix = if ([string]::Equals($Kind, "outbound", [System.StringComparison]::OrdinalIgnoreCase)) {
290
+ "[CodexLink Reply]"
291
+ } else {
292
+ "[CodexLink]"
293
+ }
294
+ $line = "$prefix $Notice`r`n"
295
+ [uint32]$written = 0
296
+ $ok = [CodexLink.NativeMethods]::WriteConsole($handle, $line, [uint32]$line.Length, [ref]$written, [IntPtr]::Zero)
297
+ [void][CodexLink.NativeMethods]::FreeConsole()
298
+ return $ok
299
+ } catch {
300
+ return $false
301
+ }
302
+ }
303
+
304
+ function Get-UiNoticeSnapshot {
305
+ param([object]$State)
306
+
307
+ if ($null -eq $State -or $null -eq $State.lastUiNotice) {
308
+ return $null
309
+ }
310
+
311
+ $text = Normalize-Preview -Value ([string]$State.lastUiNotice.text) -MaxLength 220
312
+ if (-not $text) {
313
+ return $null
314
+ }
315
+
316
+ return [pscustomobject]@{
317
+ kind = [string]$State.lastUiNotice.kind
318
+ text = $text
319
+ }
320
+ }
321
+
322
+ function Get-QueueNotice {
323
+ param(
324
+ [object]$State,
325
+ [int]$IdleCooldownMs = 15000
326
+ )
327
+
328
+ if ($null -eq $State -or $null -eq $State.queue) {
329
+ return ""
330
+ }
331
+
332
+ $queued = @($State.queue | Where-Object { $_.status -eq "queued" })
333
+ $pendingReplies = @(Get-OpenPendingReplies -State $State)
334
+ $totalWaiting = $queued.Count + $pendingReplies.Count
335
+ if ($totalWaiting -eq 0) {
336
+ return ""
337
+ }
338
+
339
+ $directCount = @($queued | Where-Object { @("direct", "lane") -contains [string]$_.relevance }).Count
340
+ $ambientCount = @($queued | Where-Object { [string]$_.relevance -eq "ambient" }).Count
341
+ $escalationCount = @($queued | Where-Object { [string]$_.relevance -eq "escalation" }).Count
342
+ $pendingCount = $pendingReplies.Count
343
+
344
+ $focus = @($queued | Where-Object { @("direct", "lane", "escalation") -contains [string]$_.relevance } | Select-Object -First 1)
345
+ if ($focus.Count -eq 0) {
346
+ $focus = @($queued | Select-Object -First 1)
347
+ }
348
+ $pendingFocus = @($pendingReplies | Sort-Object @{ Expression = { [string]$_.createdAt } } | Select-Object -First 1)
349
+
350
+ $parts = @("$totalWaiting waiting")
351
+ if ($pendingCount -gt 0) { $parts += "pending $pendingCount" }
352
+ if ($directCount -gt 0) { $parts += "direct $directCount" }
353
+ if ($ambientCount -gt 0) { $parts += "group $ambientCount" }
354
+ if ($escalationCount -gt 0) { $parts += "escalation $escalationCount" }
355
+ $parts += (Get-QueueWaitReason -State $State -IdleCooldownMs $IdleCooldownMs)
356
+
357
+ if ($pendingFocus.Count -gt 0) {
358
+ $preview = Format-QueuePreview -Entry $pendingFocus[0] -MaxLength 72 -Pending
359
+ if ($preview) {
360
+ $parts += $preview
361
+ }
362
+ } elseif ($focus.Count -gt 0) {
363
+ $preview = Format-QueuePreview -Entry $focus[0] -MaxLength 72
364
+ if ($preview) {
365
+ $parts += $preview
366
+ }
367
+ }
368
+
369
+ return ($parts -join " | ")
370
+ }
371
+
372
+ $idleCooldownMs = 15000
373
+ $ambientTtlMs = 600000
374
+ try {
375
+ $stateDir = Split-Path -Parent $StateFile
376
+ $envPath = Join-Path $stateDir ".env"
377
+ if (Test-Path $envPath) {
378
+ foreach ($line in (Get-Content -Path $envPath)) {
379
+ if (-not $line) { continue }
380
+ if ($line.Trim().StartsWith("#")) { continue }
381
+ $parts = $line -split "=", 2
382
+ if ($parts.Count -ne 2) { continue }
383
+ $key = $parts[0].Trim()
384
+ $value = $parts[1].Trim()
385
+ if ($key -eq "BLUN_TELEGRAM_IDLE_COOLDOWN_MS") {
386
+ $idleCooldownMs = [int]$value
387
+ }
388
+ }
389
+ }
390
+ } catch {
391
+ $idleCooldownMs = 15000
392
+ }
393
+
394
+ $lastTitle = ""
395
+ $lastNotice = ""
396
+ $lastUiKind = ""
397
+ $lastUiNotice = ""
398
+ Write-WatcherLog "START"
399
+
400
+ while ($true) {
401
+ if (-not (Test-PidAlive -ProcId $FrontendPid)) {
402
+ Write-WatcherLog "EXIT frontend_dead"
403
+ break
404
+ }
405
+
406
+ $runtime = Try-ReadJson -Path $RuntimeFile
407
+ if ($null -eq $runtime) {
408
+ Write-WatcherLog "WAIT runtime_missing"
409
+ Start-Sleep -Milliseconds 300
410
+ continue
411
+ }
412
+
413
+ $runtimeOwnerPid = 0
414
+ try { $runtimeOwnerPid = [int]$runtime.frontend_host_pid } catch { $runtimeOwnerPid = 0 }
415
+ if ($runtimeOwnerPid -ne $FrontendPid) {
416
+ Write-WatcherLog ("EXIT owner_changed owner=" + $runtimeOwnerPid)
417
+ break
418
+ }
419
+
420
+ $state = Try-ReadJson -Path $StateFile
421
+ if ($null -eq $state) {
422
+ Write-WatcherLog "WAIT state_missing"
423
+ Start-Sleep -Milliseconds 700
424
+ continue
425
+ }
426
+
427
+ $title = Get-QueueTitle -State $state -FallbackTitle $BaseTitle -IdleCooldownMs $idleCooldownMs
428
+ $notice = Get-QueueNotice -State $state -IdleCooldownMs $idleCooldownMs
429
+ $ui = Get-UiNoticeSnapshot -State $state
430
+ if ($title -ne $lastTitle) {
431
+ $updated = Update-ConsoleTitle -Title $title
432
+ Write-WatcherLog ("TITLE updated=" + $updated + " text=" + $title)
433
+ $lastTitle = $title
434
+ }
435
+ if ($notice -ne $lastNotice) {
436
+ if ($notice) {
437
+ $noticeUpdated = Write-ConsoleNotice -Notice $notice
438
+ Write-WatcherLog ("NOTICE updated=" + $noticeUpdated + " text=" + $notice)
439
+ } else {
440
+ Write-WatcherLog "NOTICE clear"
441
+ }
442
+ $lastNotice = $notice
443
+ }
444
+ $uiKind = if ($null -ne $ui) { [string]$ui.kind } else { "" }
445
+ $uiText = if ($null -ne $ui) { [string]$ui.text } else { "" }
446
+ if ($uiText -and ($uiText -ne $lastUiNotice -or $uiKind -ne $lastUiKind)) {
447
+ $uiUpdated = Write-ConsoleUiNotice -Kind $uiKind -Notice $uiText
448
+ Write-WatcherLog ("UI updated=" + $uiUpdated + " kind=" + $uiKind + " text=" + $uiText)
449
+ $lastUiKind = $uiKind
450
+ $lastUiNotice = $uiText
451
+ }
452
+
453
+ Start-Sleep -Milliseconds 900
454
+ }