@blunking/codexlink 0.1.1 → 0.1.9

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.
@@ -3,28 +3,149 @@ import { spawn } from "node:child_process";
3
3
  import { join } from "node:path";
4
4
  import { startTextTurnOverWs } from "./app-server-client.js";
5
5
 
6
+ function repairMojibake(value) {
7
+ const input = String(value || "");
8
+ if (!input || !/[ÃÂâð]/.test(input)) {
9
+ return input;
10
+ }
11
+ try {
12
+ const repaired = Buffer.from(input, "latin1").toString("utf8");
13
+ if (!repaired || repaired.includes("\uFFFD")) {
14
+ return input;
15
+ }
16
+ return repaired;
17
+ } catch {
18
+ return input;
19
+ }
20
+ }
21
+
22
+ function compactInboundLabel(message) {
23
+ const user = repairMojibake(String(message.user || "Unbekannt")).trim() || "Unbekannt";
24
+ const group = repairMojibake(String(message.groupTitle || "")).trim();
25
+ const chatType = String(message.chatType || "").trim();
26
+
27
+ if (group || chatType === "group" || chatType === "supergroup") {
28
+ return `${user} @ ${group || "Gruppe"} schrieb:`;
29
+ }
30
+
31
+ return `${user} schrieb:`;
32
+ }
33
+
34
+ function normalizeWhitespace(text) {
35
+ return repairMojibake(String(text || ""))
36
+ .replace(/\r/g, " ")
37
+ .replace(/\n/g, " ")
38
+ .replace(/\s+/g, " ")
39
+ .trim();
40
+ }
41
+
42
+ function firstMeaningfulLine(lines) {
43
+ for (const raw of lines) {
44
+ const line = String(raw || "").trim();
45
+ if (!line) {
46
+ continue;
47
+ }
48
+ if (/^---\s*BRIEF/i.test(line) || /^---\s*BRIEF END/i.test(line)) {
49
+ continue;
50
+ }
51
+ if (/^##\s*(Title|Project|Request|Constraints|Acceptance|Report Back)\s*$/i.test(line)) {
52
+ continue;
53
+ }
54
+ return line;
55
+ }
56
+ return "";
57
+ }
58
+
59
+ function summarizeBrief(text) {
60
+ const raw = String(text || "");
61
+ const lines = raw.split(/\r?\n/);
62
+ const briefHeader = lines.find((line) => /^---\s*BRIEF\b/i.test(String(line || "").trim())) || "";
63
+ const idMatch = briefHeader.match(/\bid=(\d+)/i);
64
+ const fromMatch = briefHeader.match(/\bfrom=([^\s]+)/i);
65
+ const titleIndex = lines.findIndex((line) => /^##\s*Title\s*$/i.test(String(line || "").trim()));
66
+ const titleLine = titleIndex >= 0 ? firstMeaningfulLine(lines.slice(titleIndex + 1, titleIndex + 4)) : "";
67
+ const firstLine = firstMeaningfulLine(lines);
68
+ let detail = titleLine || firstLine || "Neuer Brief";
69
+ detail = detail.replace(/^\[IDLE-CYCLE\]\s*/i, "IDLE-CYCLE: ");
70
+ detail = normalizeWhitespace(detail);
71
+ const from = fromMatch ? fromMatch[1] : "Brief";
72
+ const idPart = idMatch ? ` #${idMatch[1]}` : "";
73
+ if (from === "mnemo-idle-loop") {
74
+ const compactIdle = detail
75
+ .replace(/^IDLE-CYCLE:\s*/i, "")
76
+ .replace(/^Pull project_state,\s*/i, "")
77
+ .replace(/generate proposals via mem_propose,\s*/i, "proposals, ")
78
+ .replace(/ship if ship_eligible\.?/i, "ship-check")
79
+ .replace(/Mode:\s*autonomous\.?/i, "auto")
80
+ .trim();
81
+ return `Mnemo Idle${idPart}: ${compactIdle || "IDLE-CYCLE"}`;
82
+ }
83
+ return `Brief von ${from}${idPart}: ${detail}`;
84
+ }
85
+
86
+ function compactInboundText(message) {
87
+ const text = String(message.text || "").trim();
88
+ if (!text) {
89
+ return "";
90
+ }
91
+ if (/^---\s*BRIEF\b/i.test(text)) {
92
+ return summarizeBrief(text);
93
+ }
94
+ return text;
95
+ }
96
+
97
+ function normalizeAddressText(value) {
98
+ return repairMojibake(String(value || ""))
99
+ .normalize("NFKC")
100
+ .toLowerCase()
101
+ .replace(/^@+/, "")
102
+ .replace(/[^\p{L}\p{N}_-]+/gu, " ")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
105
+ }
106
+
107
+ export function isAddressOnlyPing(config, text) {
108
+ const normalizedText = normalizeAddressText(text);
109
+ if (!normalizedText || normalizedText.includes(" ")) {
110
+ return false;
111
+ }
112
+
113
+ const names = [
114
+ ...(Array.isArray(config.mentionNames) ? config.mentionNames : []),
115
+ config.agentName
116
+ ]
117
+ .map((value) => normalizeAddressText(value))
118
+ .filter((value) => value && value !== "default");
119
+
120
+ return names.includes(normalizedText);
121
+ }
122
+
6
123
  function buildPrompt(config, message) {
7
- return [
8
- "[BLUN Telegram Inbound]",
9
- `Agent: ${config.agentName}`,
10
- ...(config.lane ? [`Lane: ${config.lane}`] : []),
11
- `Chat ID: ${message.chatId}`,
12
- `Message ID: ${message.messageId}`,
13
- `User: ${message.user || "unknown"}`,
14
- `Chat Type: ${message.chatType || "unknown"}`,
15
- `Conversation Key: ${message.conversationKey || `${message.chatId}:dm`}`,
16
- ...(message.groupTitle ? [`Group: ${message.groupTitle}`] : []),
17
- ...(message.telegramThreadId ? [`Telegram Thread ID: ${message.telegramThreadId}`] : []),
18
- `Timestamp: ${message.ts}`,
19
- "",
20
- "Treat the following as a real inbound user message for this exact existing thread.",
21
- "Reply naturally in-thread. Do not mention bridge transport unless relevant.",
22
- "If this came from a group or topic, keep the reply scoped to that exact conversation.",
23
- ...(config.lane ? [`Stay strictly inside your assigned lane (${config.lane}). Do not claim ownership or make decisions for other lanes.`] : []),
24
- "",
25
- "Message:",
26
- message.text
27
- ].join("\n");
124
+ const compactText = compactInboundText(message);
125
+ const isBriefSummary = compactText.startsWith("Brief von ") || compactText.startsWith("Mnemo Idle");
126
+ const label = isBriefSummary ? "" : compactInboundLabel(message);
127
+ const header = [];
128
+
129
+ if (label) {
130
+ header.push(label);
131
+ }
132
+ header.push(compactText);
133
+
134
+ if (message.intent === "continue_nudge") {
135
+ header.push(
136
+ "",
137
+ "[Weiter-Signal: kein bloßes Ack senden. Nur antworten, wenn jetzt ein konkretes Ergebnis, Blocker oder eine Entscheidung sichtbar gemacht werden muss.]"
138
+ );
139
+ }
140
+
141
+ if (isAddressOnlyPing(config, compactText)) {
142
+ header.push(
143
+ "",
144
+ "[Ping: Der User prueft nur, ob du erreichbar bist. Antworte kurz, dass du da bist. Starte keine Suche und keinen Tool-Lauf.]"
145
+ );
146
+ }
147
+
148
+ return header.join("\n");
28
149
  }
29
150
 
30
151
  export async function injectIntoThread(config, message, threadId) {
@@ -25,6 +25,15 @@ function parseAllowedChatIds(rawValue) {
25
25
  .filter(Boolean);
26
26
  }
27
27
 
28
+ function parseMentionNames(rawValue) {
29
+ return Array.from(new Set(
30
+ String(rawValue || "")
31
+ .split(",")
32
+ .map((value) => value.trim().toLowerCase())
33
+ .filter(Boolean)
34
+ ));
35
+ }
36
+
28
37
  export function loadConfig() {
29
38
  ensureStateLayout();
30
39
  const paths = getPaths();
@@ -36,6 +45,18 @@ export function loadConfig() {
36
45
  delete fallbackEnv.BLUN_TELEGRAM_THREAD_ID;
37
46
  const env = { ...fallbackEnv, ...process.env, ...fileEnv };
38
47
  const allowedChatIds = parseAllowedChatIds(env.BLUN_TELEGRAM_ALLOWED_CHAT_ID || env.TELEGRAM_ALLOWED_CHAT_ID || "");
48
+ const mentionNames = parseMentionNames(
49
+ env.BLUN_TELEGRAM_MENTION_NAMES
50
+ || env.BLUN_CODEX_AGENT
51
+ || env.TELEGRAM_AGENT_NAME
52
+ || env.BLUN_TELEGRAM_AGENT_NAME
53
+ || ""
54
+ );
55
+ const otherAgentNames = parseMentionNames(
56
+ env.BLUN_TELEGRAM_OTHER_AGENT_NAMES
57
+ || env.BLUN_CODEX_OTHER_AGENTS
58
+ || ""
59
+ );
39
60
  return {
40
61
  paths,
41
62
  agentName: env.BLUN_TELEGRAM_AGENT_NAME?.trim() || env.TELEGRAM_AGENT_NAME?.trim() || "default",
@@ -43,11 +64,18 @@ export function loadConfig() {
43
64
  botToken: env.BLUN_TELEGRAM_BOT_TOKEN?.trim() || env.TELEGRAM_BOT_TOKEN?.trim() || "",
44
65
  allowedChatId: allowedChatIds[0] || "",
45
66
  allowedChatIds,
67
+ mentionNames,
68
+ otherAgentNames,
46
69
  codexBin: env.BLUN_TELEGRAM_CODEX_BIN?.trim() || "codex",
47
70
  appServerWsUrl: env.BLUN_TELEGRAM_APP_SERVER_WS_URL?.trim() || "",
48
71
  currentThreadId: env.BLUN_TELEGRAM_THREAD_ID?.trim() || process.env.CODEX_THREAD_ID?.trim() || "",
49
72
  resumeTimeoutMs: Number.parseInt(env.BLUN_TELEGRAM_RESUME_TIMEOUT_MS || "15000", 10) || 15000,
50
- idleCooldownMs: Number.parseInt(env.BLUN_TELEGRAM_IDLE_COOLDOWN_MS || "15000", 10) || 15000,
73
+ idleCooldownMs: Number.parseInt(env.BLUN_TELEGRAM_IDLE_COOLDOWN_MS || "3000", 10) || 3000,
74
+ ambientQueueTtlMs: Number.parseInt(env.BLUN_TELEGRAM_AMBIENT_QUEUE_TTL_MS || "600000", 10) || 600000,
75
+ pendingReplyTimeoutMs: Number.parseInt(env.BLUN_TELEGRAM_PENDING_REPLY_TIMEOUT_MS || "1800000", 10) || 1800000,
76
+ progressFallbackMs: Number.parseInt(env.BLUN_TELEGRAM_PROGRESS_FALLBACK_MS || "20000", 10) || 20000,
77
+ progressRelayMode: env.BLUN_TELEGRAM_PROGRESS_RELAY?.trim().toLowerCase() || "status",
78
+ queueNoticeEnabled: /^(1|true|yes|on)$/i.test(env.BLUN_TELEGRAM_QUEUE_NOTICE || ""),
51
79
  dispatchMode: env.BLUN_TELEGRAM_DISPATCH_MODE?.trim() || "deferred",
52
80
  pluginMode: env.BLUN_TELEGRAM_PLUGIN_MODE?.trim() || "inherit",
53
81
  model: env.BLUN_CODEX_MODEL?.trim() || "",
@@ -9,10 +9,16 @@ export function getStateDir() {
9
9
  export function getPaths() {
10
10
  const root = getStateDir();
11
11
  const codexHome = join(homedir(), ".codex");
12
+ const agentName = process.env.BLUN_TELEGRAM_AGENT_NAME?.trim()
13
+ || process.env.TELEGRAM_AGENT_NAME?.trim()
14
+ || "default";
15
+ const runtimeDir = join(codexHome, "runtimes", agentName);
12
16
  return {
13
17
  root,
14
18
  legacyRoot: join(codexHome, "channels", "codexlink-telegram"),
15
19
  codexHome,
20
+ runtimeDir,
21
+ currentRuntimeFile: join(runtimeDir, "current-remote-runtime.json"),
16
22
  sessionsDir: join(codexHome, "sessions"),
17
23
  envFile: join(root, ".env"),
18
24
  stateFile: join(root, "state.json"),
@@ -36,7 +42,7 @@ export function getPaths() {
36
42
 
37
43
  export function ensureStateLayout() {
38
44
  const paths = getPaths();
39
- for (const dir of [paths.root, paths.promptsDir, paths.responsesDir]) {
45
+ for (const dir of [paths.root, paths.promptsDir, paths.responsesDir, paths.runtimeDir]) {
40
46
  if (!existsSync(dir)) {
41
47
  mkdirSync(dir, { recursive: true });
42
48
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, openSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { spawn } from "node:child_process";
4
+ import { execFileSync, spawn } from "node:child_process";
5
5
  import { appendLog } from "./storage.js";
6
6
 
7
7
  const here = dirname(fileURLToPath(import.meta.url));
@@ -31,6 +31,14 @@ function stopPid(pid) {
31
31
  if (!isPidAlive(pid)) {
32
32
  return false;
33
33
  }
34
+ if (process.platform === "win32") {
35
+ try {
36
+ execFileSync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
34
42
  try {
35
43
  process.kill(pid, "SIGTERM");
36
44
  } catch {
@@ -60,10 +68,13 @@ function ensureSidecar(scriptName, pidFile, stdoutFile, stderrFile, config, opti
60
68
  BLUN_TELEGRAM_STATE_DIR: config.paths.root,
61
69
  BLUN_TELEGRAM_BOT_TOKEN: config.botToken || "",
62
70
  BLUN_TELEGRAM_ALLOWED_CHAT_ID: Array.isArray(config.allowedChatIds) ? config.allowedChatIds.join(",") : (config.allowedChatId || ""),
71
+ BLUN_TELEGRAM_OTHER_AGENT_NAMES: Array.isArray(config.otherAgentNames) ? config.otherAgentNames.join(",") : "",
63
72
  BLUN_TELEGRAM_APP_SERVER_WS_URL: config.appServerWsUrl || "",
64
73
  BLUN_TELEGRAM_CODEX_BIN: config.codexBin || "codex",
65
74
  BLUN_TELEGRAM_RESUME_TIMEOUT_MS: String(config.resumeTimeoutMs || 15000),
66
75
  BLUN_TELEGRAM_IDLE_COOLDOWN_MS: String(config.idleCooldownMs || 15000),
76
+ BLUN_TELEGRAM_PROGRESS_FALLBACK_MS: String(config.progressFallbackMs || 20000),
77
+ BLUN_TELEGRAM_QUEUE_NOTICE: config.queueNoticeEnabled ? "1" : "0",
67
78
  BLUN_TELEGRAM_DISPATCH_MODE: config.dispatchMode || "deferred",
68
79
  BLUN_TELEGRAM_PLUGIN_MODE: config.pluginMode || "plugin",
69
80
  BLUN_CODEX_MODEL: config.model || "",
@@ -0,0 +1,66 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { getPaths } from "./paths.js";
3
+
4
+ function readPid(path) {
5
+ try {
6
+ return Number.parseInt(readFileSync(path, "utf8").trim(), 10) || 0;
7
+ } catch {
8
+ return 0;
9
+ }
10
+ }
11
+
12
+ function writePid(path) {
13
+ try {
14
+ writeFileSync(path, `${process.pid}\n`, "utf8");
15
+ } catch {
16
+ // Best-effort self-heal only; the sidecar can still keep running.
17
+ }
18
+ }
19
+
20
+ function isPidAlive(pid) {
21
+ if (!pid || pid <= 0) {
22
+ return false;
23
+ }
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function isCurrentSidecarPid(kind) {
33
+ const paths = getPaths();
34
+ const pidFiles = {
35
+ poller: paths.pollerPidFile,
36
+ dispatcher: paths.dispatcherPidFile,
37
+ responder: paths.responderPidFile
38
+ };
39
+ const pidFile = pidFiles[kind];
40
+ if (!pidFile) {
41
+ return true;
42
+ }
43
+
44
+ const currentPid = readPid(pidFile);
45
+ if (!currentPid) {
46
+ writePid(pidFile);
47
+ return true;
48
+ }
49
+
50
+ if (currentPid === process.pid) {
51
+ return true;
52
+ }
53
+
54
+ // The parent writes the pid file just after spawn. Give a fresh child a
55
+ // short grace window so it does not exit before its pid has been recorded.
56
+ if (process.uptime() < 2) {
57
+ return true;
58
+ }
59
+
60
+ if (!isPidAlive(currentPid)) {
61
+ writePid(pidFile);
62
+ return true;
63
+ }
64
+
65
+ return false;
66
+ }
@@ -1,21 +1,60 @@
1
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
-
3
- export function nowIso() {
4
- return new Date().toISOString();
5
- }
6
-
7
- export function loadJson(path, fallback) {
8
- try {
9
- const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
10
- return JSON.parse(raw);
11
- } catch {
12
- return fallback;
13
- }
14
- }
15
-
16
- export function saveJson(path, value) {
17
- writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
18
- }
1
+ import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+
4
+ function sleepSync(ms) {
5
+ const buffer = new SharedArrayBuffer(4);
6
+ const view = new Int32Array(buffer);
7
+ Atomics.wait(view, 0, 0, ms);
8
+ }
9
+
10
+ export function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ export function loadJson(path, fallback) {
15
+ for (let attempt = 0; attempt < 6; attempt += 1) {
16
+ try {
17
+ const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
18
+ if (!raw.trim()) {
19
+ throw new Error("empty json file");
20
+ }
21
+ return JSON.parse(raw);
22
+ } catch {
23
+ if (attempt < 5) {
24
+ sleepSync(20 * (attempt + 1));
25
+ }
26
+ }
27
+ }
28
+ return fallback;
29
+ }
30
+
31
+ export function saveJson(path, value) {
32
+ const text = JSON.stringify(value, null, 2);
33
+ const dir = dirname(path);
34
+ const base = basename(path);
35
+ let lastError = null;
36
+
37
+ for (let attempt = 0; attempt < 6; attempt += 1) {
38
+ const tempPath = join(dir, `.${base}.${process.pid}.${Date.now()}.${attempt}.tmp`);
39
+ try {
40
+ writeFileSync(tempPath, text, "utf8");
41
+ renameSync(tempPath, path);
42
+ return;
43
+ } catch (error) {
44
+ lastError = error;
45
+ try {
46
+ unlinkSync(tempPath);
47
+ } catch {
48
+ // Ignore cleanup failures for temp files.
49
+ }
50
+ if (attempt < 5) {
51
+ sleepSync(25 * (attempt + 1));
52
+ }
53
+ }
54
+ }
55
+
56
+ throw lastError || new Error(`Failed to save JSON file: ${path}`);
57
+ }
19
58
 
20
59
  export function appendJsonl(path, value) {
21
60
  appendFileSync(path, `${JSON.stringify(value)}\n`, "utf8");
@@ -41,10 +80,12 @@ export function defaultState() {
41
80
  pendingReplies: [],
42
81
  replyOffsets: {},
43
82
  replyBuffers: {},
44
- lastInbound: null,
45
- lastOutbound: null,
46
- lastPollAt: null,
47
- lastInjectAt: null,
48
- lastAutoDispatchAt: null
49
- };
50
- }
83
+ lastInbound: null,
84
+ lastOutbound: null,
85
+ lastUiNotice: null,
86
+ lastPollAt: null,
87
+ lastInjectAt: null,
88
+ lastAutoDispatchAt: null,
89
+ lastQueueNoticeAt: null
90
+ };
91
+ }
@@ -35,3 +35,11 @@ export async function sendMessage(config, { chatId, text, replyToMessageId, tele
35
35
  ...(telegramThreadId ? { message_thread_id: Number(telegramThreadId) } : {})
36
36
  });
37
37
  }
38
+
39
+ export async function sendChatAction(config, { chatId, telegramThreadId, action = "typing" }) {
40
+ return telegramRequest(config, "sendChatAction", {
41
+ chat_id: chatId,
42
+ action,
43
+ ...(telegramThreadId ? { message_thread_id: Number(telegramThreadId) } : {})
44
+ });
45
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { pollOnce } from "./lib/bridge.js";
3
+ import { isCurrentSidecarPid } from "./lib/singleton.js";
3
4
 
4
5
  const intervalMs = Number.parseInt(process.env.BLUN_TELEGRAM_POLL_INTERVAL_MS || "1500", 10) || 1500;
5
6
  let stopping = false;
@@ -18,6 +19,9 @@ process.on("SIGTERM", () => {
18
19
 
19
20
  async function main() {
20
21
  while (!stopping) {
22
+ if (!isCurrentSidecarPid("poller")) {
23
+ break;
24
+ }
21
25
  try {
22
26
  const result = await pollOnce();
23
27
  if (result.captured > 0 || result.ignored > 0) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { relayRepliesOnce } from "./lib/bridge.js";
3
+ import { isCurrentSidecarPid } from "./lib/singleton.js";
3
4
 
4
5
  const intervalMs = Number.parseInt(process.env.BLUN_TELEGRAM_REPLY_INTERVAL_MS || "1500", 10) || 1500;
5
6
  let stopping = false;
@@ -18,6 +19,9 @@ process.on("SIGTERM", () => {
18
19
 
19
20
  async function main() {
20
21
  while (!stopping) {
22
+ if (!isCurrentSidecarPid("responder")) {
23
+ break;
24
+ }
21
25
  try {
22
26
  const result = await relayRepliesOnce();
23
27
  if (result.status !== "empty" && result.delivered > 0) {
@@ -72,7 +72,7 @@ function Test-AllowedChatIdsFormat {
72
72
  return $true
73
73
  }
74
74
 
75
- function Prompt-RequiredValue {
75
+ function Prompt-RequiredValue {
76
76
  param(
77
77
  [string]$Prompt,
78
78
  [string]$CurrentValue,
@@ -91,12 +91,37 @@ function Prompt-RequiredValue {
91
91
  return $inputValue
92
92
  }
93
93
  Write-Host $ErrorMessage -ForegroundColor Yellow
94
- }
95
- }
96
-
97
- $runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
98
- $profilePath = Join-Path $runtimeRoot ("profiles\" + $Profile.ToLower() + ".json")
99
- $profileJson = Try-ReadJson -Path $profilePath
94
+ }
95
+ }
96
+
97
+ function Get-ProfilePath {
98
+ param(
99
+ [string]$RuntimeRoot,
100
+ [string]$ProfileName
101
+ )
102
+
103
+ $normalized = [string]$ProfileName
104
+ if (-not $normalized) { $normalized = "" }
105
+ $normalized = $normalized.ToLower()
106
+ $candidates = @()
107
+ if ($env:BLUN_CODEX_PROFILE_ROOT) {
108
+ $candidates += (Join-Path $env:BLUN_CODEX_PROFILE_ROOT ($normalized + ".json"))
109
+ }
110
+ $candidates += (Join-Path $env:USERPROFILE (".codex\\profiles\\codexlink\\" + $normalized + ".json"))
111
+ $candidates += (Join-Path $RuntimeRoot ("profiles\\" + $normalized + ".json"))
112
+
113
+ foreach ($candidate in $candidates) {
114
+ if ($candidate -and (Test-Path $candidate)) {
115
+ return $candidate
116
+ }
117
+ }
118
+
119
+ return $candidates[-1]
120
+ }
121
+
122
+ $runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
123
+ $profilePath = Get-ProfilePath -RuntimeRoot $runtimeRoot -ProfileName $Profile
124
+ $profileJson = Try-ReadJson -Path $profilePath
100
125
 
101
126
  if (-not $profileJson) {
102
127
  throw "Profile not found or invalid: $profilePath"
@@ -123,42 +148,50 @@ if (-not (Test-AllowedChatIdsFormat -Value $currentAllowedChatIds)) {
123
148
  $currentAllowedChatIds = [string]$envValues["TELEGRAM_ALLOWED_CHAT_ID"]
124
149
  }
125
150
 
126
- $needsToken = -not (Test-TelegramTokenFormat -Value $currentToken)
127
- $needsChatIds = -not (Test-AllowedChatIdsFormat -Value $currentAllowedChatIds)
128
- $changed = $false
129
-
130
- if ($EnsureConfigured -and -not $needsToken -and -not $needsChatIds) {
131
- $result = [ordered]@{
132
- ok = $true
133
- changed = $false
134
- profile = $profileAgent
135
- state_dir = $stateDir
136
- env_path = $envPath
137
- missing = @()
138
- }
139
- if ($Json) {
140
- $result | ConvertTo-Json -Depth 6
141
- } else {
142
- Write-Host "Telegram ist bereits eingerichtet fuer Profil '$profileAgent'." -ForegroundColor Green
143
- Write-Host "State-Ordner: $stateDir"
144
- }
145
- exit 0
146
- }
147
-
148
- if ($EnsureConfigured -and $Json -and ($needsToken -or $needsChatIds)) {
149
- $missing = @()
150
- if ($needsToken) { $missing += "bot_token" }
151
- if ($needsChatIds) { $missing += "allowed_chat_ids" }
152
- [ordered]@{
153
- ok = $false
154
- changed = $false
155
- profile = $profileAgent
156
- state_dir = $stateDir
157
- env_path = $envPath
158
- missing = $missing
159
- } | ConvertTo-Json -Depth 6
160
- exit 2
161
- }
151
+ $needsToken = -not (Test-TelegramTokenFormat -Value $currentToken)
152
+ $needsChatIds = -not (Test-AllowedChatIdsFormat -Value $currentAllowedChatIds)
153
+ $changed = $false
154
+
155
+ if ($EnsureConfigured -and -not $needsToken) {
156
+ $result = [ordered]@{
157
+ ok = $true
158
+ changed = $false
159
+ profile = $profileAgent
160
+ state_dir = $stateDir
161
+ env_path = $envPath
162
+ missing = @()
163
+ optional = @(
164
+ "allowed_chat_ids"
165
+ )
166
+ }
167
+ if ($Json) {
168
+ $result | ConvertTo-Json -Depth 6
169
+ } else {
170
+ Write-Host "Telegram ist bereits eingerichtet fuer Profil '$profileAgent'." -ForegroundColor Green
171
+ Write-Host "State-Ordner: $stateDir"
172
+ if ($needsChatIds) {
173
+ Write-Host "Hinweis: keine Chat-Allowlist gesetzt. Der Bot akzeptiert aktuell alle Chats, die er sehen kann." -ForegroundColor Yellow
174
+ }
175
+ }
176
+ exit 0
177
+ }
178
+
179
+ if ($EnsureConfigured -and $Json -and $needsToken) {
180
+ $missing = @()
181
+ if ($needsToken) { $missing += "bot_token" }
182
+ [ordered]@{
183
+ ok = $false
184
+ changed = $false
185
+ profile = $profileAgent
186
+ state_dir = $stateDir
187
+ env_path = $envPath
188
+ missing = $missing
189
+ optional = @(
190
+ "allowed_chat_ids"
191
+ )
192
+ } | ConvertTo-Json -Depth 6
193
+ exit 2
194
+ }
162
195
 
163
196
  if (-not $Json) {
164
197
  Write-Host ""
@@ -167,9 +200,10 @@ if (-not $Json) {
167
200
  Write-Host "Lokaler State-Ordner: $stateDir"
168
201
  Write-Host ""
169
202
  Write-Host "Ich speichere die Telegram-Werte automatisch an die richtige lokale Stelle." -ForegroundColor DarkGray
170
- Write-Host "Du musst keine .env-Datei selbst suchen." -ForegroundColor DarkGray
171
- Write-Host ""
172
- }
203
+ Write-Host "Du musst keine .env-Datei selbst suchen." -ForegroundColor DarkGray
204
+ Write-Host "Chat-ID-Allowlist ist optional und blockiert den Start nicht mehr." -ForegroundColor DarkGray
205
+ Write-Host ""
206
+ }
173
207
 
174
208
  if ($needsToken) {
175
209
  $currentToken = Prompt-RequiredValue `
@@ -181,15 +215,15 @@ if ($needsToken) {
181
215
  $changed = $true
182
216
  }
183
217
 
184
- if ($needsChatIds) {
185
- $currentAllowedChatIds = Prompt-RequiredValue `
186
- -Prompt "Erlaubte Chat ID(s), komma-getrennt" `
187
- -CurrentValue $currentAllowedChatIds `
188
- -Validator { param($v) Test-AllowedChatIdsFormat -Value $v } `
218
+ if (-not $EnsureConfigured -and $needsChatIds) {
219
+ $currentAllowedChatIds = Prompt-RequiredValue `
220
+ -Prompt "Erlaubte Chat ID(s), komma-getrennt" `
221
+ -CurrentValue $currentAllowedChatIds `
222
+ -Validator { param($v) Test-AllowedChatIdsFormat -Value $v } `
189
223
  -ErrorMessage "Bitte mindestens eine numerische Chat-ID eingeben. Mehrere IDs mit Komma trennen."
190
224
  $envValues["BLUN_TELEGRAM_ALLOWED_CHAT_ID"] = (($currentAllowedChatIds -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join ",")
191
- $changed = $true
192
- }
225
+ $changed = $true
226
+ }
193
227
 
194
228
  $envValues["BLUN_TELEGRAM_AGENT_NAME"] = $profileAgent
195
229
  $envValues["BLUN_TELEGRAM_STATE_DIR"] = $stateDir