@blunking/codexlink 0.1.16 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blunking/codexlink",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "BLUN CLI launcher with Telegram channel support for one visible session.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -21,9 +21,10 @@
21
21
  "telegram-title-embed.ps1",
22
22
  "telegram-status.ps1",
23
23
  "telegram-doctor.ps1",
24
- "telegram-setup.ps1",
25
- "telegram-title-watcher.ps1"
26
- ],
24
+ "telegram-setup.ps1",
25
+ "telegram-console-input.ps1",
26
+ "telegram-title-watcher.ps1"
27
+ ],
27
28
  "keywords": [
28
29
  "blun",
29
30
  "codexlink",
@@ -469,12 +469,17 @@ if ($telegramEnvOverride) {
469
469
  $commonOverrides += $telegramEnvOverride
470
470
  }
471
471
 
472
- $codexArgs = @()
473
-
474
- if ($profile.reasoning_effort) {
475
- $codexArgs += "-c"
476
- $codexArgs += ("model_reasoning_effort=""" + $profile.reasoning_effort + """")
477
- }
472
+ $codexArgs = @()
473
+
474
+ if ($profile.model) {
475
+ $codexArgs += "--model"
476
+ $codexArgs += $profile.model
477
+ }
478
+
479
+ if ($profile.reasoning_effort) {
480
+ $codexArgs += "-c"
481
+ $codexArgs += ("model_reasoning_effort=""" + $profile.reasoning_effort + """")
482
+ }
478
483
 
479
484
  if ($profile.personality) {
480
485
  $codexArgs += "-c"
@@ -749,20 +754,28 @@ if ($useRemoteAppServer) {
749
754
  if ($loadedIds.Count -gt 0) {
750
755
  $activeThreadId = [string]$loadedIds[$loadedIds.Count - 1]
751
756
  $bestThreadScore = [double]::NegativeInfinity
757
+ $runtimeStartedAtMs = 0
758
+ try {
759
+ $runtimeStartedAtMs = [DateTimeOffset]::Parse([string]$currentRuntime.started_at).ToUnixTimeMilliseconds()
760
+ } catch {
761
+ $runtimeStartedAtMs = 0
762
+ }
752
763
  foreach ($candidate in $loadedIds) {
753
764
  $candidateThreadId = [string]$candidate
754
765
  if ([string]::IsNullOrWhiteSpace($candidateThreadId)) {
755
766
  continue
756
767
  }
757
768
  $threadScore = 0.0
769
+ $threadCreatedAtMs = 0.0
758
770
  try {
759
771
  $candidateInfo = Invoke-NodeJsonWithRetry -NodeArgs @($bootstrapScript, "read-thread", "--ws-url", $telegramAppServerWsUrl, "--thread-id", $candidateThreadId) -Attempts 1 -DelayMs 0
760
772
  $thread = $candidateInfo.response.result.thread
761
773
  if ($null -ne $thread -and $null -ne $thread.createdAt) {
762
- $threadScore = [double]$thread.createdAt
763
- if ($threadScore -gt 0 -and $threadScore -lt 1000000000000) {
764
- $threadScore = $threadScore * 1000
774
+ $threadCreatedAtMs = [double]$thread.createdAt
775
+ if ($threadCreatedAtMs -gt 0 -and $threadCreatedAtMs -lt 1000000000000) {
776
+ $threadCreatedAtMs = $threadCreatedAtMs * 1000
765
777
  }
778
+ $threadScore = $threadCreatedAtMs
766
779
  }
767
780
  $threadSource = ""
768
781
  $threadStatusType = ""
@@ -773,11 +786,14 @@ if ($useRemoteAppServer) {
773
786
  $threadStatusType = ([string]$thread.status.type).ToLowerInvariant()
774
787
  }
775
788
  if ($threadSource -eq "cli" -and $threadStatusType -eq "active") {
776
- $threadScore += 1000000000000000
789
+ $threadScore += 4000000000000000
777
790
  } elseif ($threadStatusType -eq "active") {
778
- $threadScore += 900000000000000
791
+ $threadScore += 3000000000000000
779
792
  } elseif ($threadSource -eq "cli") {
780
- $threadScore += 800000000000000
793
+ $threadScore += 2000000000000000
794
+ }
795
+ if ($runtimeStartedAtMs -gt 0 -and $threadCreatedAtMs -ge ($runtimeStartedAtMs - 120000)) {
796
+ $threadScore += 1000000000000000
781
797
  }
782
798
  } catch {
783
799
  $threadScore = 0.0
@@ -0,0 +1,151 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [int]$TargetPid,
4
+
5
+ [string]$Text = "",
6
+
7
+ [switch]$ClearBefore,
8
+
9
+ [switch]$Submit
10
+ )
11
+
12
+ $ErrorActionPreference = "Stop"
13
+
14
+ $typeName = "ConsoleInputWriter"
15
+ $assemblyDir = Join-Path $env:TEMP "blun-codexlink"
16
+ $assemblyPath = Join-Path $assemblyDir "console-input-writer-v4.dll"
17
+
18
+ $source = @"
19
+ using System;
20
+ using System.Runtime.InteropServices;
21
+
22
+ public static class ConsoleInputWriter {
23
+ private const int STD_INPUT_HANDLE = -10;
24
+ private const short KEY_EVENT = 0x0001;
25
+ private const ushort VK_RETURN = 0x0D;
26
+ private const ushort VK_BACK = 0x08;
27
+ private const ushort VK_U = 0x55;
28
+ private const ushort SCAN_RETURN = 0x1C;
29
+ private const ushort SCAN_U = 0x16;
30
+ private const uint LEFT_CTRL_PRESSED = 0x0008;
31
+ private const uint GENERIC_READ = 0x80000000;
32
+ private const uint GENERIC_WRITE = 0x40000000;
33
+ private const uint FILE_SHARE_READ = 0x00000001;
34
+ private const uint FILE_SHARE_WRITE = 0x00000002;
35
+ private const uint OPEN_EXISTING = 3;
36
+
37
+ [StructLayout(LayoutKind.Sequential)]
38
+ public struct KEY_EVENT_RECORD {
39
+ [MarshalAs(UnmanagedType.Bool)]
40
+ public bool bKeyDown;
41
+ public ushort wRepeatCount;
42
+ public ushort wVirtualKeyCode;
43
+ public ushort wVirtualScanCode;
44
+ public char UnicodeChar;
45
+ public uint dwControlKeyState;
46
+ }
47
+
48
+ [StructLayout(LayoutKind.Explicit)]
49
+ public struct INPUT_RECORD {
50
+ [FieldOffset(0)]
51
+ public short EventType;
52
+ [FieldOffset(4)]
53
+ public KEY_EVENT_RECORD KeyEvent;
54
+ }
55
+
56
+ [DllImport("kernel32.dll", SetLastError=true)]
57
+ private static extern bool AttachConsole(uint dwProcessId);
58
+
59
+ [DllImport("kernel32.dll", SetLastError=true)]
60
+ private static extern bool FreeConsole();
61
+
62
+ [DllImport("kernel32.dll", SetLastError=true)]
63
+ private static extern IntPtr GetStdHandle(int nStdHandle);
64
+
65
+ [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
66
+ private static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
67
+
68
+ [DllImport("kernel32.dll", SetLastError=true)]
69
+ private static extern bool CloseHandle(IntPtr hObject);
70
+
71
+ [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
72
+ private static extern bool WriteConsoleInputW(IntPtr hConsoleInput, INPUT_RECORD[] lpBuffer, uint nLength, out uint lpNumberOfEventsWritten);
73
+
74
+ public static void WriteText(int targetPid, string text, bool clearBefore, bool submit) {
75
+ FreeConsole();
76
+ if (!AttachConsole((uint)targetPid)) {
77
+ throw new InvalidOperationException("AttachConsole failed: " + Marshal.GetLastWin32Error());
78
+ }
79
+
80
+ IntPtr input = CreateFileW("CONIN$", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
81
+ if (input == IntPtr.Zero || input == new IntPtr(-1)) {
82
+ input = GetStdHandle(STD_INPUT_HANDLE);
83
+ }
84
+ if (input == IntPtr.Zero || input == new IntPtr(-1)) {
85
+ throw new InvalidOperationException("Open console input failed: " + Marshal.GetLastWin32Error());
86
+ }
87
+
88
+ try {
89
+ if (clearBefore) {
90
+ WriteKey(input, (char)21, VK_U, SCAN_U, LEFT_CTRL_PRESSED);
91
+ }
92
+
93
+ foreach (char ch in text) {
94
+ WriteKey(input, ch, 0);
95
+ }
96
+
97
+ if (submit) {
98
+ WriteKey(input, '\r', VK_RETURN, SCAN_RETURN);
99
+ }
100
+ } finally {
101
+ CloseHandle(input);
102
+ FreeConsole();
103
+ }
104
+ }
105
+
106
+ private static void WriteKey(IntPtr input, char ch, ushort virtualKey) {
107
+ WriteKey(input, ch, virtualKey, 0);
108
+ }
109
+
110
+ private static void WriteKey(IntPtr input, char ch, ushort virtualKey, ushort virtualScanCode) {
111
+ WriteKey(input, ch, virtualKey, virtualScanCode, 0);
112
+ }
113
+
114
+ private static void WriteKey(IntPtr input, char ch, ushort virtualKey, ushort virtualScanCode, uint controlKeyState) {
115
+ INPUT_RECORD[] records = new INPUT_RECORD[2];
116
+ records[0].EventType = KEY_EVENT;
117
+ records[0].KeyEvent.bKeyDown = true;
118
+ records[0].KeyEvent.wRepeatCount = 1;
119
+ records[0].KeyEvent.wVirtualKeyCode = virtualKey;
120
+ records[0].KeyEvent.wVirtualScanCode = virtualScanCode;
121
+ records[0].KeyEvent.UnicodeChar = ch;
122
+ records[0].KeyEvent.dwControlKeyState = controlKeyState;
123
+
124
+ records[1].EventType = KEY_EVENT;
125
+ records[1].KeyEvent.bKeyDown = false;
126
+ records[1].KeyEvent.wRepeatCount = 1;
127
+ records[1].KeyEvent.wVirtualKeyCode = virtualKey;
128
+ records[1].KeyEvent.wVirtualScanCode = virtualScanCode;
129
+ records[1].KeyEvent.UnicodeChar = ch;
130
+ records[1].KeyEvent.dwControlKeyState = controlKeyState;
131
+
132
+ uint written;
133
+ if (!WriteConsoleInputW(input, records, (uint)records.Length, out written) || written != records.Length) {
134
+ throw new InvalidOperationException("WriteConsoleInputW failed: " + Marshal.GetLastWin32Error());
135
+ }
136
+ }
137
+ }
138
+ "@
139
+
140
+ if (-not ($typeName -as [type])) {
141
+ if (Test-Path $assemblyPath) {
142
+ Add-Type -Path $assemblyPath
143
+ } else {
144
+ New-Item -ItemType Directory -Path $assemblyDir -Force | Out-Null
145
+ Add-Type -TypeDefinition $source -OutputAssembly $assemblyPath -OutputType Library
146
+ Add-Type -Path $assemblyPath
147
+ }
148
+ }
149
+
150
+ $normalizedText = $Text -replace "`r`n", " " -replace "`n", " " -replace "`r", " "
151
+ [ConsoleInputWriter]::WriteText($TargetPid, $normalizedText, [bool]$ClearBefore, [bool]$Submit)
@@ -10,8 +10,9 @@ It is intentionally **not** an autonomous answer bot.
10
10
  - stores inbound and outbound history under a local state directory
11
11
  - keeps private chats and group threads separated
12
12
  - binds a live thread id
13
- - injects the next queued Telegram message into that exact live thread only after the session is idle
14
- - keeps injected Telegram messages visible as pending until the matching answer is sent
13
+ - injects direct/private/lane Telegram messages into the active app-server thread
14
+ - steers active turns when the app-server supports it
15
+ - keeps queued Telegram messages visible when they are waiting instead of ready to deliver
15
16
  - sends explicit manual replies from the visible operator session
16
17
  - keeps ambient group noise queued unless it is relevant to that operator
17
18
  - lets escalation-style messages bypass the normal idle queue
@@ -47,7 +48,7 @@ Copy `.env.example` to `.env` in the state directory or export env vars:
47
48
 
48
49
  - `BLUN_TELEGRAM_AGENT_NAME`
49
50
  - `BLUN_TELEGRAM_BOT_TOKEN`
50
- - `BLUN_TELEGRAM_ALLOWED_CHAT_ID` (`chatId` or comma-separated list like `1605241602,-1003927574737`)
51
+ - `BLUN_TELEGRAM_ALLOWED_CHAT_ID` (`chatId` or comma-separated list like `123456789,-1001234567890`)
51
52
  - `BLUN_TELEGRAM_CODEX_BIN`
52
53
  - `BLUN_TELEGRAM_THREAD_ID`
53
54
  - `BLUN_TELEGRAM_RESUME_TIMEOUT_MS`
@@ -55,12 +55,44 @@ function extractTurnId(response) {
55
55
  || "";
56
56
  }
57
57
 
58
+ function extractActiveTurnId(turnsResponse) {
59
+ const turns = Array.isArray(turnsResponse?.result?.data) ? turnsResponse.result.data : [];
60
+ const active = turns.find((turn) => String(turn?.status || "").trim() === "inProgress");
61
+ return String(active?.id || "").trim();
62
+ }
63
+
58
64
  function extractThreadPath(response) {
59
65
  return response?.result?.thread?.path
60
66
  || response?.result?.path
61
67
  || "";
62
68
  }
63
69
 
70
+ function normalizeUserInput(input) {
71
+ const items = Array.isArray(input) ? input : [];
72
+ return items.map((item) => {
73
+ if (!item || typeof item !== "object") {
74
+ return item;
75
+ }
76
+ if (item.type === "text" && !Array.isArray(item.text_elements)) {
77
+ return {
78
+ ...item,
79
+ text_elements: []
80
+ };
81
+ }
82
+ return item;
83
+ });
84
+ }
85
+
86
+ function buildTextInput(text) {
87
+ return [
88
+ {
89
+ type: "text",
90
+ text,
91
+ text_elements: []
92
+ }
93
+ ];
94
+ }
95
+
64
96
  export class AppServerClient {
65
97
  constructor(wsUrl, options = {}) {
66
98
  this.wsUrl = normalizeWsUrl(wsUrl);
@@ -220,14 +252,12 @@ export async function startThreadOverWs(options) {
220
252
  export async function startTextTurnOverWs(options) {
221
253
  const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 20000 });
222
254
  try {
255
+ const input = Array.isArray(options.input) && options.input.length > 0
256
+ ? normalizeUserInput(options.input)
257
+ : buildTextInput(options.text);
223
258
  const response = await client.request("turn/start", {
224
259
  threadId: options.threadId,
225
- input: [
226
- {
227
- type: "text",
228
- text: options.text
229
- }
230
- ],
260
+ input,
231
261
  model: options.model || null,
232
262
  effort: options.effort || null,
233
263
  personality: options.personality || null
@@ -256,11 +286,115 @@ export async function startTextTurnOverWs(options) {
256
286
  }
257
287
  }
258
288
 
289
+ export async function startOrSteerTextTurnOverWs(options) {
290
+ const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 20000 });
291
+ try {
292
+ const input = Array.isArray(options.input) && options.input.length > 0
293
+ ? normalizeUserInput(options.input)
294
+ : buildTextInput(options.text);
295
+
296
+ let activeTurnId = "";
297
+ try {
298
+ const turnsResponse = await client.request("thread/turns/list", {
299
+ threadId: options.threadId,
300
+ limit: 8,
301
+ itemsView: "notLoaded"
302
+ }, { timeoutMs: Math.min(options.timeoutMs || 20000, 5000) });
303
+ activeTurnId = extractActiveTurnId(turnsResponse);
304
+ } catch {
305
+ activeTurnId = "";
306
+ }
307
+
308
+ if (activeTurnId) {
309
+ try {
310
+ const steerResponse = await client.request("turn/steer", {
311
+ threadId: options.threadId,
312
+ expectedTurnId: activeTurnId,
313
+ input,
314
+ responsesapiClientMetadata: {
315
+ source: "telegram"
316
+ }
317
+ }, { timeoutMs: options.timeoutMs || 20000 });
318
+
319
+ return {
320
+ ok: true,
321
+ busy: false,
322
+ steered: true,
323
+ turnId: extractTurnId(steerResponse) || activeTurnId,
324
+ response: steerResponse
325
+ };
326
+ } catch (error) {
327
+ const details = `${error?.message || error}`.toLowerCase();
328
+ const notSteerable = details.includes("activeturnnotsteerable")
329
+ || details.includes("not steerable")
330
+ || details.includes("cannot accept same-turn steering");
331
+ const staleTurn = details.includes("expectedturnid")
332
+ || details.includes("precondition")
333
+ || details.includes("does not match")
334
+ || details.includes("no active turn");
335
+ if (notSteerable) {
336
+ return {
337
+ ok: false,
338
+ busy: true,
339
+ steered: true,
340
+ turnId: activeTurnId,
341
+ error
342
+ };
343
+ }
344
+ if (!staleTurn) {
345
+ return {
346
+ ok: false,
347
+ busy: true,
348
+ steered: true,
349
+ turnId: activeTurnId,
350
+ error
351
+ };
352
+ }
353
+ }
354
+ }
355
+
356
+ const response = await client.request("turn/start", {
357
+ threadId: options.threadId,
358
+ input,
359
+ model: options.model || null,
360
+ effort: options.effort || null,
361
+ personality: options.personality || null,
362
+ responsesapiClientMetadata: {
363
+ source: "telegram"
364
+ }
365
+ }, { timeoutMs: options.timeoutMs || 20000 });
366
+
367
+ return {
368
+ ok: true,
369
+ busy: false,
370
+ steered: false,
371
+ turnId: extractTurnId(response),
372
+ response
373
+ };
374
+ } catch (error) {
375
+ const details = `${error?.message || error}`.toLowerCase();
376
+ const busy = details.includes("active turn")
377
+ || details.includes("cannot accept")
378
+ || details.includes("already running")
379
+ || details.includes("busy");
380
+
381
+ return {
382
+ ok: false,
383
+ busy,
384
+ steered: false,
385
+ error
386
+ };
387
+ } finally {
388
+ await client.close();
389
+ }
390
+ }
391
+
259
392
  export async function readThreadOverWs(options) {
260
393
  const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 10000 });
261
394
  try {
262
395
  const response = await client.request("thread/read", {
263
- threadId: options.threadId
396
+ threadId: options.threadId,
397
+ includeTurns: Boolean(options.includeTurns)
264
398
  }, { timeoutMs: options.timeoutMs || 10000 });
265
399
  const threadId = extractThreadId(response);
266
400
  return {