ticlawk 0.1.12-dev.0

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 (55) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +426 -0
  3. package/agent-freeway.mjs +2 -0
  4. package/assets/ticlawk-concept.svg +137 -0
  5. package/bin/agent-freeway.mjs +4 -0
  6. package/bin/ticlawk.mjs +594 -0
  7. package/cc-watcher.mjs +3 -0
  8. package/package.json +72 -0
  9. package/scripts/postinstall.mjs +61 -0
  10. package/src/adapters/telegram/index.mjs +359 -0
  11. package/src/adapters/ticlawk/api.mjs +360 -0
  12. package/src/adapters/ticlawk/cards.mjs +149 -0
  13. package/src/adapters/ticlawk/credentials.mjs +25 -0
  14. package/src/adapters/ticlawk/index.mjs +1229 -0
  15. package/src/adapters/ticlawk/wake-client.mjs +204 -0
  16. package/src/core/adapter-registry.mjs +50 -0
  17. package/src/core/argv.mjs +38 -0
  18. package/src/core/bindings/store.mjs +81 -0
  19. package/src/core/bus.mjs +91 -0
  20. package/src/core/config.mjs +203 -0
  21. package/src/core/daemon-install.mjs +246 -0
  22. package/src/core/diagnostics.mjs +79 -0
  23. package/src/core/events/worker-events.mjs +80 -0
  24. package/src/core/executables.mjs +106 -0
  25. package/src/core/host-id.mjs +48 -0
  26. package/src/core/http.mjs +65 -0
  27. package/src/core/logger.mjs +34 -0
  28. package/src/core/media/inbound.mjs +127 -0
  29. package/src/core/media/outbound.mjs +163 -0
  30. package/src/core/profiles.mjs +173 -0
  31. package/src/core/runtime-contract.mjs +68 -0
  32. package/src/core/runtime-env.mjs +9 -0
  33. package/src/core/runtime-registry.mjs +93 -0
  34. package/src/core/runtime-support.mjs +197 -0
  35. package/src/core/setup-readiness.mjs +86 -0
  36. package/src/core/store/json-file-store.mjs +47 -0
  37. package/src/core/ticlawk-control.mjs +92 -0
  38. package/src/core/uninstall.mjs +142 -0
  39. package/src/core/update-state.mjs +62 -0
  40. package/src/core/update.mjs +178 -0
  41. package/src/runtimes/claude-code/index.mjs +363 -0
  42. package/src/runtimes/claude-code/session.mjs +388 -0
  43. package/src/runtimes/claude-code/transcripts.mjs +206 -0
  44. package/src/runtimes/codex/index.mjs +306 -0
  45. package/src/runtimes/codex/session.mjs +750 -0
  46. package/src/runtimes/openclaw/gateway.mjs +269 -0
  47. package/src/runtimes/openclaw/identity.mjs +34 -0
  48. package/src/runtimes/openclaw/index.mjs +228 -0
  49. package/src/runtimes/openclaw/inflight.mjs +46 -0
  50. package/src/runtimes/openclaw/target.mjs +57 -0
  51. package/src/runtimes/opencode/index.mjs +318 -0
  52. package/src/runtimes/opencode/session.mjs +413 -0
  53. package/src/runtimes/pi/index.mjs +287 -0
  54. package/src/runtimes/pi/session.mjs +423 -0
  55. package/ticlawk.mjs +260 -0
@@ -0,0 +1,204 @@
1
+ import WebSocket from 'ws';
2
+
3
+ const RECONNECT_BASE_MS = 1000;
4
+ const RECONNECT_MAX_MS = 30000;
5
+
6
+ function parseMessage(data) {
7
+ const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data || '');
8
+ return JSON.parse(text);
9
+ }
10
+
11
+ function reconnectDelayMs(attempt) {
12
+ const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * (2 ** Math.max(0, attempt - 1)));
13
+ return Math.floor(base * (0.5 + Math.random()));
14
+ }
15
+
16
+ export class TiclawkWakeClient {
17
+ constructor({ getUrl, getApiKey, onEvent, onStatus, logger }) {
18
+ this.getUrl = getUrl;
19
+ this.getApiKey = getApiKey;
20
+ this.onEvent = onEvent;
21
+ this.onStatus = onStatus;
22
+ this.logger = logger;
23
+ this.ws = null;
24
+ this.stopped = true;
25
+ this.connected = false;
26
+ this.reconnectAttempt = 0;
27
+ this.reconnectTimer = null;
28
+ this.lastEventAt = null;
29
+ this.lastError = null;
30
+ this.currentUrl = '';
31
+ }
32
+
33
+ start() {
34
+ if (!this.stopped) return;
35
+ this.stopped = false;
36
+ this.connect();
37
+ }
38
+
39
+ stop() {
40
+ this.stopped = true;
41
+ this.connected = false;
42
+ if (this.reconnectTimer) {
43
+ clearTimeout(this.reconnectTimer);
44
+ this.reconnectTimer = null;
45
+ }
46
+ if (this.ws) {
47
+ try { this.ws.close(); } catch {}
48
+ this.ws = null;
49
+ }
50
+ }
51
+
52
+ isConnected() {
53
+ return this.connected;
54
+ }
55
+
56
+ status() {
57
+ return {
58
+ connected: this.connected,
59
+ url: this.currentUrl,
60
+ lastEventAt: this.lastEventAt,
61
+ lastError: this.lastError,
62
+ reconnectAttempt: this.reconnectAttempt,
63
+ };
64
+ }
65
+
66
+ emitStatus(state, meta = {}) {
67
+ this.onStatus?.({
68
+ state,
69
+ connected: this.connected,
70
+ ...meta,
71
+ });
72
+ }
73
+
74
+ connect() {
75
+ if (this.stopped) return;
76
+ const apiKey = String(this.getApiKey?.() || '').trim();
77
+ this.currentUrl = String(this.getUrl?.() || '').trim();
78
+ if (!apiKey) {
79
+ this.lastError = 'missing connector api key';
80
+ this.emitStatus('error', { error: this.lastError, url: this.currentUrl });
81
+ this.scheduleReconnect();
82
+ return;
83
+ }
84
+ if (!this.currentUrl) {
85
+ this.lastError = 'missing connector wake url';
86
+ this.emitStatus('error', { error: this.lastError });
87
+ this.scheduleReconnect();
88
+ return;
89
+ }
90
+
91
+ this.connected = false;
92
+ this.emitStatus('connecting', { url: this.currentUrl });
93
+
94
+ const ws = new WebSocket(this.currentUrl, {
95
+ headers: {
96
+ Authorization: `Bearer ${apiKey}`,
97
+ },
98
+ });
99
+ this.ws = ws;
100
+
101
+ ws.on('message', (data) => {
102
+ let msg;
103
+ try {
104
+ msg = parseMessage(data);
105
+ } catch (err) {
106
+ this.lastError = `invalid wake message: ${err?.message || String(err)}`;
107
+ this.logger?.debugError?.('ticlawk-wake', 'message.invalid', {
108
+ error: this.lastError,
109
+ });
110
+ return;
111
+ }
112
+ this.handleMessage(msg);
113
+ });
114
+
115
+ ws.on('error', (err) => {
116
+ this.lastError = err?.message || 'websocket error';
117
+ this.logger?.debugError?.('ticlawk-wake', 'socket.error', {
118
+ error: this.lastError,
119
+ });
120
+ this.emitStatus('error', { error: this.lastError });
121
+ });
122
+
123
+ ws.on('close', (code, reason) => {
124
+ const wasConnected = this.connected;
125
+ this.connected = false;
126
+ if (this.ws === ws) this.ws = null;
127
+ if (this.stopped) return;
128
+ this.emitStatus('closed', {
129
+ code,
130
+ reason: reason ? reason.toString('utf8') : '',
131
+ wasConnected,
132
+ });
133
+ this.scheduleReconnect();
134
+ });
135
+ }
136
+
137
+ handleMessage(msg) {
138
+ const type = msg?.type || 'unknown';
139
+ this.lastEventAt = new Date().toISOString();
140
+
141
+ if (type === 'hello') {
142
+ this.connected = true;
143
+ this.reconnectAttempt = 0;
144
+ this.lastError = null;
145
+ this.emitStatus('connected', {
146
+ protocol: msg.protocol ?? null,
147
+ connectionId: msg.connection_id || null,
148
+ });
149
+ this.onEvent?.(msg);
150
+ return;
151
+ }
152
+
153
+ if (type === 'heartbeat') {
154
+ this.send({ type: 'pong', ts: msg.ts || new Date().toISOString() });
155
+ this.onEvent?.(msg);
156
+ return;
157
+ }
158
+
159
+ if (type === 'auth.revoked') {
160
+ this.connected = false;
161
+ this.lastError = 'auth revoked';
162
+ this.emitStatus('auth.revoked', { error: this.lastError });
163
+ this.onEvent?.(msg);
164
+ this.stop();
165
+ return;
166
+ }
167
+
168
+ if (type === 'jobs.available' || type === 'bindings.changed') {
169
+ this.onEvent?.(msg);
170
+ return;
171
+ }
172
+
173
+ this.logger?.debugLog?.('ticlawk-wake', 'message.ignored', { type });
174
+ }
175
+
176
+ send(payload) {
177
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
178
+ try {
179
+ this.ws.send(JSON.stringify(payload));
180
+ return true;
181
+ } catch (err) {
182
+ this.lastError = err?.message || 'websocket send failed';
183
+ this.logger?.debugError?.('ticlawk-wake', 'send.failed', {
184
+ error: this.lastError,
185
+ });
186
+ return false;
187
+ }
188
+ }
189
+
190
+ scheduleReconnect() {
191
+ if (this.stopped || this.reconnectTimer) return;
192
+ this.reconnectAttempt += 1;
193
+ const delayMs = reconnectDelayMs(this.reconnectAttempt);
194
+ this.emitStatus('reconnecting', {
195
+ attempt: this.reconnectAttempt,
196
+ delayMs,
197
+ });
198
+ this.reconnectTimer = setTimeout(() => {
199
+ this.reconnectTimer = null;
200
+ this.connect();
201
+ }, delayMs);
202
+ this.reconnectTimer.unref?.();
203
+ }
204
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ createTiclawkAdapter,
3
+ getTiclawkAuthHelp,
4
+ runTiclawkAuth,
5
+ } from '../adapters/ticlawk/index.mjs';
6
+ import {
7
+ createTelegramAdapter,
8
+ getTelegramAuthHelp,
9
+ runTelegramAuth,
10
+ } from '../adapters/telegram/index.mjs';
11
+
12
+ const FACTORIES = {
13
+ ticlawk: createTiclawkAdapter,
14
+ telegram: createTelegramAdapter,
15
+ };
16
+
17
+ const AUTH_HANDLERS = {
18
+ ticlawk: {
19
+ help: getTiclawkAuthHelp,
20
+ run: runTiclawkAuth,
21
+ },
22
+ telegram: {
23
+ help: getTelegramAuthHelp,
24
+ run: runTelegramAuth,
25
+ },
26
+ };
27
+
28
+ export function createAdapter(adapterId, ctx) {
29
+ const factory = FACTORIES[adapterId];
30
+ if (!factory) {
31
+ throw new Error(`unsupported adapter: ${adapterId}`);
32
+ }
33
+ return factory(ctx);
34
+ }
35
+
36
+ export function getAdapterAuthHelp(adapterId) {
37
+ const handler = AUTH_HANDLERS[adapterId];
38
+ if (!handler?.help) {
39
+ throw new Error(`unsupported adapter: ${adapterId}`);
40
+ }
41
+ return handler.help();
42
+ }
43
+
44
+ export async function runAdapterAuth(adapterId, rawArgs) {
45
+ const handler = AUTH_HANDLERS[adapterId];
46
+ if (!handler?.run) {
47
+ throw new Error(`unsupported adapter: ${adapterId}`);
48
+ }
49
+ return handler.run(rawArgs);
50
+ }
@@ -0,0 +1,38 @@
1
+ export function splitArgv(argv = []) {
2
+ const separatorIndex = argv.indexOf('--');
3
+ if (separatorIndex < 0) {
4
+ return {
5
+ mainArgv: [...argv],
6
+ passthroughArgv: [],
7
+ };
8
+ }
9
+ return {
10
+ mainArgv: argv.slice(0, separatorIndex),
11
+ passthroughArgv: argv.slice(separatorIndex + 1),
12
+ };
13
+ }
14
+
15
+ export function parseOptionArgs(argv = []) {
16
+ const args = { _: [] };
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const arg = argv[i];
19
+ if (arg === '-h') {
20
+ args.help = true;
21
+ continue;
22
+ }
23
+ if (arg === '-y') {
24
+ args.y = true;
25
+ continue;
26
+ }
27
+ if (arg.startsWith('--')) {
28
+ const [rawKey, inlineValue] = arg.slice(2).split(/=(.*)/s, 2);
29
+ const value = inlineValue !== undefined
30
+ ? inlineValue
31
+ : argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
32
+ args[rawKey] = value;
33
+ continue;
34
+ }
35
+ args._.push(arg);
36
+ }
37
+ return args;
38
+ }
@@ -0,0 +1,81 @@
1
+ import { join } from 'node:path';
2
+ import { AF_HOME } from '../config.mjs';
3
+ import { JsonFileStore } from '../store/json-file-store.mjs';
4
+ import { getActiveBindingsPath } from '../profiles.mjs';
5
+
6
+ const LEGACY_BINDINGS_PATH = join(AF_HOME, 'bindings.json');
7
+ const stores = new Map();
8
+
9
+ function getStore() {
10
+ const filePath = getActiveBindingsPath(LEGACY_BINDINGS_PATH);
11
+ let store = stores.get(filePath);
12
+ if (!store) {
13
+ store = new JsonFileStore(filePath, []);
14
+ stores.set(filePath, store);
15
+ }
16
+ return store;
17
+ }
18
+
19
+ function normalizeBinding(binding) {
20
+ const now = new Date().toISOString();
21
+ const hostId = binding.runtime_host_id || binding.targetMeta?.runtime_host_id;
22
+ const runtimeHostLabel = binding.runtime_host_label || binding.targetMeta?.runtime_host_label;
23
+ return {
24
+ id: binding.id || binding.targetKey,
25
+ adapter: binding.adapter,
26
+ targetKey: binding.targetKey,
27
+ targetMeta: binding.targetMeta || {},
28
+ ...(hostId ? { runtime_host_id: hostId } : {}),
29
+ ...(runtimeHostLabel ? { runtime_host_label: runtimeHostLabel } : {}),
30
+ runtime: binding.runtime,
31
+ runtimeMeta: binding.runtimeMeta || {},
32
+ displayName: binding.displayName || binding.targetKey,
33
+ status: binding.status || 'paired',
34
+ createdAt: binding.createdAt || now,
35
+ updatedAt: now,
36
+ };
37
+ }
38
+
39
+ export async function upsertBinding(binding) {
40
+ const normalized = normalizeBinding(binding);
41
+ let saved = normalized;
42
+ await getStore().update((current) => {
43
+ const next = Array.isArray(current) ? [...current] : [];
44
+ const index = next.findIndex((entry) => entry.id === normalized.id);
45
+ if (index >= 0) {
46
+ next[index] = {
47
+ ...next[index],
48
+ ...normalized,
49
+ createdAt: next[index].createdAt || normalized.createdAt,
50
+ updatedAt: normalized.updatedAt,
51
+ };
52
+ saved = next[index];
53
+ } else {
54
+ next.push(normalized);
55
+ saved = normalized;
56
+ }
57
+ return next;
58
+ });
59
+ return saved;
60
+ }
61
+
62
+ export function listBindings(filter = {}) {
63
+ const bindings = getStore().read();
64
+ return bindings.filter((binding) => {
65
+ if (filter.adapter && binding.adapter !== filter.adapter) return false;
66
+ if (filter.runtime && binding.runtime !== filter.runtime) return false;
67
+ return true;
68
+ });
69
+ }
70
+
71
+ export function getBinding(bindingId) {
72
+ return listBindings().find((binding) => binding.id === bindingId) || null;
73
+ }
74
+
75
+ export function findBindingByTarget(adapter, targetKey) {
76
+ return listBindings({ adapter }).find((binding) => binding.targetKey === targetKey) || null;
77
+ }
78
+
79
+ export async function deleteBinding(bindingId) {
80
+ await getStore().update((current) => current.filter((binding) => binding.id !== bindingId));
81
+ }
@@ -0,0 +1,91 @@
1
+ import { debugError } from './logger.mjs';
2
+
3
+ /**
4
+ * The Bus.
5
+ *
6
+ * The Bus is the inbound serialization queue.
7
+ *
8
+ * Runtimes register themselves under a name (`claude_code`, `codex`,
9
+ * `openclaw`, ...). Adapters call `dispatchToAgent` to route an inbound
10
+ * message into the correct runtime.
11
+ *
12
+ * Inbound serialization (adapter → runtime) is enforced per
13
+ * `(runtimeName, key)` so the same agent session can't be poked
14
+ * concurrently. The key is opaque to the bus — adapters typically pass a
15
+ * channel id or a session id.
16
+ *
17
+ * Outbound delivery does not go through the bus; runtimes call adapter
18
+ * methods directly through the runtime context passed in by core.
19
+ */
20
+
21
+ export class Bus {
22
+ constructor() {
23
+ this._runtimes = new Map(); // name -> async (msg) => any
24
+ this._inFlight = new Map(); // `${name}:${key}` -> Promise
25
+ this._listeners = new Map(); // event -> Set<fn>
26
+ }
27
+
28
+ /**
29
+ * Register a runtime handler under `name`.
30
+ * `handler(msg)` is invoked by `dispatchToAgent` and may return a
31
+ * Promise that resolves when the runtime has handled the message.
32
+ */
33
+ registerRuntime(name, handler) {
34
+ if (typeof handler !== 'function') {
35
+ throw new Error(`Bus.registerRuntime: handler for "${name}" must be a function`);
36
+ }
37
+ this._runtimes.set(name, handler);
38
+ }
39
+
40
+ hasRuntime(name) {
41
+ return this._runtimes.has(name);
42
+ }
43
+
44
+ /**
45
+ * Route an inbound message to a runtime, serialized per
46
+ * `(runtimeName, key)`. Subsequent calls with the same key wait for
47
+ * the previous one to settle (resolved or rejected).
48
+ */
49
+ async dispatchToAgent(runtimeName, key, msg) {
50
+ const handler = this._runtimes.get(runtimeName);
51
+ if (!handler) {
52
+ throw new Error(`Bus.dispatchToAgent: no runtime registered: ${runtimeName}`);
53
+ }
54
+
55
+ const lockKey = `${runtimeName}:${key || ''}`;
56
+ const prev = this._inFlight.get(lockKey) || Promise.resolve();
57
+ const next = prev.catch(() => undefined).then(() => handler(msg));
58
+ this._inFlight.set(lockKey, next);
59
+ try {
60
+ return await next;
61
+ } finally {
62
+ if (this._inFlight.get(lockKey) === next) {
63
+ this._inFlight.delete(lockKey);
64
+ }
65
+ }
66
+ }
67
+ on(event, fn) {
68
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
69
+ this._listeners.get(event).add(fn);
70
+ }
71
+
72
+ off(event, fn) {
73
+ const set = this._listeners.get(event);
74
+ if (set) set.delete(fn);
75
+ }
76
+
77
+ emit(event, payload) {
78
+ const set = this._listeners.get(event);
79
+ if (!set) return;
80
+ for (const fn of set) {
81
+ try {
82
+ fn(payload);
83
+ } catch (err) {
84
+ debugError('bus', 'listener threw during emit', {
85
+ event,
86
+ error: err?.message || String(err),
87
+ });
88
+ }
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Persistent ticlawk configuration.
3
+ *
4
+ * Owns the on-disk paths under ~/.ticlawk/ and the dotenv-formatted
5
+ * `.config` file. Importing this module triggers a one-time dotenv load,
6
+ * so it MUST be imported before any module that reads `process.env` at
7
+ * module-eval time.
8
+ */
9
+
10
+ import dotenv from 'dotenv';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { RUNTIME_DEFINITIONS, normalizeServiceType } from './runtime-registry.mjs';
15
+
16
+ const DEFAULT_TICLAWK_HOME = join(homedir(), '.ticlawk');
17
+ const LEGACY_AGENT_FREEWAY_HOME = join(homedir(), '.agent-freeway');
18
+
19
+ function resolveTiclawkHome() {
20
+ if (process.env.TICLAWK_HOME) return process.env.TICLAWK_HOME;
21
+ if (process.env.AGENT_FREEWAY_HOME) return process.env.AGENT_FREEWAY_HOME;
22
+ if (existsSync(DEFAULT_TICLAWK_HOME)) return DEFAULT_TICLAWK_HOME;
23
+ if (existsSync(LEGACY_AGENT_FREEWAY_HOME)) return LEGACY_AGENT_FREEWAY_HOME;
24
+ return DEFAULT_TICLAWK_HOME;
25
+ }
26
+
27
+ export const AF_HOME = resolveTiclawkHome();
28
+ export const AF_CONFIG_PATH = join(AF_HOME, '.config');
29
+ export const AF_LOG_PATH = join(AF_HOME, 'ticlawk.log');
30
+ export const AF_CRASH_LOG_PATH = join(AF_HOME, 'ticlawk-crash.log');
31
+ export const AF_ADAPTER_KEY = 'AF_ADAPTER';
32
+ export const AF_STREAMING_KEY = 'AF_STREAMING';
33
+ export const AF_STREAMING_RUNTIME_KEYS = Object.fromEntries(
34
+ RUNTIME_DEFINITIONS
35
+ .filter((runtime) => runtime.streamingEnvKey)
36
+ .map((runtime) => [runtime.name, runtime.streamingEnvKey])
37
+ );
38
+ export const TICLAWK_CONNECTOR_API_KEY = 'TICLAWK_CONNECTOR_API_KEY';
39
+ export const TICLAWK_CONNECTOR_WS_URL = 'TICLAWK_CONNECTOR_WS_URL';
40
+ export const LEGACY_TICLAWK_API_KEY = 'TICLAWK_API_KEY';
41
+ export const RUNTIME_EXECUTABLE_CONFIG_KEYS = Object.fromEntries(
42
+ RUNTIME_DEFINITIONS
43
+ .filter((runtime) => runtime.executableConfigKey)
44
+ .map((runtime) => [runtime.name, runtime.executableConfigKey])
45
+ );
46
+ export const SUPPORTED_ADAPTERS = ['ticlawk', 'telegram'];
47
+ // Public CLI keys stay kebab/dotted for usability; the persisted .config file
48
+ // stores env-style names because systemd/launchd load it directly.
49
+ export const ADAPTER_CONFIG_KEYS = {
50
+ 'telegram.bot-token': 'TELEGRAM_BOT_TOKEN',
51
+ 'ticlawk.connector-api-key': TICLAWK_CONNECTOR_API_KEY,
52
+ // Backward-compatible CLI alias. This intentionally maps to the connector
53
+ // key instead of the generic publisher-facing TICLAWK_API_KEY name.
54
+ 'ticlawk.api-key': TICLAWK_CONNECTOR_API_KEY,
55
+ 'ticlawk.api-url': 'TICLAWK_API_URL',
56
+ 'ticlawk.connector-ws-url': TICLAWK_CONNECTOR_WS_URL,
57
+ };
58
+
59
+ export function normalizeAdapterName(value) {
60
+ const normalized = String(value || '').trim().toLowerCase().replace(/[-\s]+/g, '_');
61
+ if (!normalized) return null;
62
+ if (SUPPORTED_ADAPTERS.includes(normalized)) return normalized;
63
+ return null;
64
+ }
65
+
66
+ export function getConfiguredAdapter(config = null) {
67
+ const source = config || loadPersistentConfig();
68
+ return normalizeAdapterName(source[AF_ADAPTER_KEY]) || 'ticlawk';
69
+ }
70
+
71
+ export function normalizeAdapterConfigTarget(target) {
72
+ const normalized = String(target || '').trim().toLowerCase();
73
+ const configKey = ADAPTER_CONFIG_KEYS[normalized];
74
+ if (!configKey) return null;
75
+ return {
76
+ key: normalized,
77
+ configKey,
78
+ };
79
+ }
80
+
81
+ export function normalizeRuntimeConfigTarget(target) {
82
+ const normalized = String(target || '').trim().toLowerCase();
83
+ const match = normalized.match(/^runtimes\.([a-z0-9_-]+)\.path$/);
84
+ if (!match) return null;
85
+ const runtimeName = normalizeServiceType(match[1]) || match[1].replace(/-/g, '_');
86
+ const configKey = RUNTIME_EXECUTABLE_CONFIG_KEYS[runtimeName];
87
+ if (!configKey) return null;
88
+ return {
89
+ key: `runtimes.${runtimeName}.path`,
90
+ runtimeName,
91
+ configKey,
92
+ };
93
+ }
94
+
95
+ export function getRuntimeExecutableConfig(runtimeName, config = null) {
96
+ const configKey = RUNTIME_EXECUTABLE_CONFIG_KEYS[runtimeName];
97
+ if (!configKey) return null;
98
+ const source = config || loadPersistentConfig();
99
+ return source[configKey] || null;
100
+ }
101
+
102
+ dotenv.config({ path: AF_CONFIG_PATH, quiet: true });
103
+
104
+ export function loadPersistentConfig() {
105
+ if (!existsSync(AF_CONFIG_PATH)) return {};
106
+ try {
107
+ return dotenv.parse(readFileSync(AF_CONFIG_PATH, 'utf8'));
108
+ } catch {
109
+ return {};
110
+ }
111
+ }
112
+
113
+ export function persistConfig(updates) {
114
+ const current = loadPersistentConfig();
115
+ const next = { ...current, ...updates };
116
+ const lines = [
117
+ '# Persistent ticlawk config',
118
+ ...Object.entries(next)
119
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
120
+ .map(([key, value]) => `${key}=${String(value)}`),
121
+ ];
122
+
123
+ mkdirSync(dirname(AF_CONFIG_PATH), { recursive: true });
124
+ writeFileSync(AF_CONFIG_PATH, `${lines.join('\n')}\n`, { mode: 0o600 });
125
+ for (const [key, value] of Object.entries(updates || {})) {
126
+ if (value === undefined || value === null || value === '') {
127
+ delete process.env[key];
128
+ continue;
129
+ }
130
+ process.env[key] = String(value);
131
+ }
132
+ }
133
+
134
+ export function migrateLegacyTiclawkConnectorKey() {
135
+ const current = loadPersistentConfig();
136
+ const legacyKey = current[LEGACY_TICLAWK_API_KEY];
137
+ if (!legacyKey) return false;
138
+
139
+ const updates = {
140
+ [LEGACY_TICLAWK_API_KEY]: '',
141
+ };
142
+ if (!current[TICLAWK_CONNECTOR_API_KEY]) {
143
+ updates[TICLAWK_CONNECTOR_API_KEY] = legacyKey;
144
+ }
145
+
146
+ persistConfig(updates);
147
+ delete process.env[LEGACY_TICLAWK_API_KEY];
148
+ return true;
149
+ }
150
+
151
+ migrateLegacyTiclawkConnectorKey();
152
+
153
+ function parseBooleanish(value) {
154
+ if (value === undefined || value === null || value === '') return null;
155
+ const normalized = String(value).trim().toLowerCase();
156
+ if (['1', 'true', 'on', 'yes'].includes(normalized)) return true;
157
+ if (['0', 'false', 'off', 'no'].includes(normalized)) return false;
158
+ return null;
159
+ }
160
+
161
+ export function normalizeStreamingTarget(target) {
162
+ const normalized = String(target || '').trim().toLowerCase();
163
+ if (normalized === 'streaming') {
164
+ return { key: 'streaming', scope: 'default', configKey: AF_STREAMING_KEY };
165
+ }
166
+ if (normalized.startsWith('streaming.')) {
167
+ const runtimeName = normalizeServiceType(normalized.slice('streaming.'.length));
168
+ if (AF_STREAMING_RUNTIME_KEYS[runtimeName]) {
169
+ return { key: `streaming.${runtimeName}`, scope: runtimeName, configKey: AF_STREAMING_RUNTIME_KEYS[runtimeName] };
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ export function getStreamingMode(runtimeName, config = null) {
176
+ const source = config || loadPersistentConfig();
177
+ const defaultMode = parseBooleanish(source[AF_STREAMING_KEY]);
178
+ const runtimeKey = AF_STREAMING_RUNTIME_KEYS[runtimeName];
179
+ const runtimeMode = runtimeKey ? parseBooleanish(source[runtimeKey]) : null;
180
+ if (runtimeMode !== null) return runtimeMode;
181
+ if (defaultMode !== null) return defaultMode;
182
+ return true;
183
+ }
184
+
185
+ export function getStreamingConfigView(config = null) {
186
+ const source = config || loadPersistentConfig();
187
+ const defaultMode = getStreamingMode('__default__', source);
188
+ const view = {
189
+ streaming: {
190
+ value: defaultMode,
191
+ inherited: false,
192
+ },
193
+ };
194
+ for (const runtime of RUNTIME_DEFINITIONS) {
195
+ const runtimeKey = AF_STREAMING_RUNTIME_KEYS[runtime.name];
196
+ if (!runtimeKey) continue;
197
+ view[`streaming.${runtime.name}`] = {
198
+ value: getStreamingMode(runtime.name, source),
199
+ inherited: parseBooleanish(source[runtimeKey]) === null,
200
+ };
201
+ }
202
+ return view;
203
+ }