@blunking/codexlink 0.1.15 → 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.15",
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"
@@ -521,12 +526,17 @@ if ($useRemoteAppServer) {
521
526
  $codexArgs += $telegramAppServerWsUrl
522
527
  } else {
523
528
  $envFilePath = Join-Path $telegramStateDir ".env"
524
- $stateEnv = Read-DotEnvFile -Path $envFilePath
525
- $stateEnv["BLUN_TELEGRAM_AGENT_NAME"] = $profile.agent_name
526
- $stateEnv["BLUN_TELEGRAM_STATE_DIR"] = $telegramStateDir
527
- if ($telegramAllowedChatId) {
528
- $stateEnv["BLUN_TELEGRAM_ALLOWED_CHAT_ID"] = $telegramAllowedChatId
529
- }
529
+ $stateEnv = Read-DotEnvFile -Path $envFilePath
530
+ $stateEnv["BLUN_TELEGRAM_AGENT_NAME"] = $profile.agent_name
531
+ $stateEnv["BLUN_TELEGRAM_STATE_DIR"] = $telegramStateDir
532
+ $stateEnv["BLUN_CODEX_DISPLAY_NAME"] = $profile.display_name
533
+ $stateEnv["BLUN_CODEX_LANE"] = $profile.lane
534
+ $stateEnv["BLUN_CODEX_PERSONALITY"] = $profile.personality
535
+ $stateEnv["BLUN_CODEX_MODEL"] = $profile.model
536
+ $stateEnv["BLUN_CODEX_REASONING_EFFORT"] = $profile.reasoning_effort
537
+ if ($telegramAllowedChatId) {
538
+ $stateEnv["BLUN_TELEGRAM_ALLOWED_CHAT_ID"] = $telegramAllowedChatId
539
+ }
530
540
  $stateEnv["BLUN_TELEGRAM_PLUGIN_MODE"] = $TelegramMode
531
541
  $stateEnv["BLUN_TELEGRAM_APP_SERVER_WS_URL"] = $telegramAppServerWsUrl
532
542
  $stateEnv["BLUN_TELEGRAM_THREAD_ID"] = ""
@@ -744,20 +754,28 @@ if ($useRemoteAppServer) {
744
754
  if ($loadedIds.Count -gt 0) {
745
755
  $activeThreadId = [string]$loadedIds[$loadedIds.Count - 1]
746
756
  $bestThreadScore = [double]::NegativeInfinity
757
+ $runtimeStartedAtMs = 0
758
+ try {
759
+ $runtimeStartedAtMs = [DateTimeOffset]::Parse([string]$currentRuntime.started_at).ToUnixTimeMilliseconds()
760
+ } catch {
761
+ $runtimeStartedAtMs = 0
762
+ }
747
763
  foreach ($candidate in $loadedIds) {
748
764
  $candidateThreadId = [string]$candidate
749
765
  if ([string]::IsNullOrWhiteSpace($candidateThreadId)) {
750
766
  continue
751
767
  }
752
768
  $threadScore = 0.0
769
+ $threadCreatedAtMs = 0.0
753
770
  try {
754
771
  $candidateInfo = Invoke-NodeJsonWithRetry -NodeArgs @($bootstrapScript, "read-thread", "--ws-url", $telegramAppServerWsUrl, "--thread-id", $candidateThreadId) -Attempts 1 -DelayMs 0
755
772
  $thread = $candidateInfo.response.result.thread
756
773
  if ($null -ne $thread -and $null -ne $thread.createdAt) {
757
- $threadScore = [double]$thread.createdAt
758
- if ($threadScore -gt 0 -and $threadScore -lt 1000000000000) {
759
- $threadScore = $threadScore * 1000
774
+ $threadCreatedAtMs = [double]$thread.createdAt
775
+ if ($threadCreatedAtMs -gt 0 -and $threadCreatedAtMs -lt 1000000000000) {
776
+ $threadCreatedAtMs = $threadCreatedAtMs * 1000
760
777
  }
778
+ $threadScore = $threadCreatedAtMs
761
779
  }
762
780
  $threadSource = ""
763
781
  $threadStatusType = ""
@@ -768,11 +786,14 @@ if ($useRemoteAppServer) {
768
786
  $threadStatusType = ([string]$thread.status.type).ToLowerInvariant()
769
787
  }
770
788
  if ($threadSource -eq "cli" -and $threadStatusType -eq "active") {
771
- $threadScore += 1000000000000000
789
+ $threadScore += 4000000000000000
772
790
  } elseif ($threadStatusType -eq "active") {
773
- $threadScore += 900000000000000
791
+ $threadScore += 3000000000000000
774
792
  } elseif ($threadSource -eq "cli") {
775
- $threadScore += 800000000000000
793
+ $threadScore += 2000000000000000
794
+ }
795
+ if ($runtimeStartedAtMs -gt 0 -and $threadCreatedAtMs -ge ($runtimeStartedAtMs - 120000)) {
796
+ $threadScore += 1000000000000000
776
797
  }
777
798
  } catch {
778
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 {