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,246 @@
1
+ /**
2
+ * Managed daemon installation.
3
+ *
4
+ * Owns the service files that keep ticlawk running in the background.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { homedir, userInfo } from 'node:os';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { AF_CONFIG_PATH, AF_LOG_PATH } from './config.mjs';
13
+
14
+ function getPackageRoot() {
15
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ }
17
+
18
+ function getBinDir() {
19
+ return join(homedir(), '.local', 'bin');
20
+ }
21
+
22
+ export function getWrapperPath() {
23
+ return join(getBinDir(), 'ticlawk');
24
+ }
25
+
26
+ export function commandExists(command) {
27
+ return spawnSync('command', ['-v', command], { shell: true, stdio: 'ignore' }).status === 0;
28
+ }
29
+
30
+ function dedupePath(raw) {
31
+ const seen = new Set();
32
+ const out = [];
33
+ for (const part of String(raw || '').split(':')) {
34
+ if (!part || seen.has(part)) continue;
35
+ seen.add(part);
36
+ out.push(part);
37
+ }
38
+ return out.join(':');
39
+ }
40
+
41
+ function buildServicePath() {
42
+ const home = homedir();
43
+ return dedupePath([
44
+ getBinDir(),
45
+ join(home, '.local', 'bin'),
46
+ join(home, '.npm-global', 'bin'),
47
+ join(home, '.bun', 'bin'),
48
+ join(home, '.cargo', 'bin'),
49
+ '/opt/homebrew/bin',
50
+ '/opt/homebrew/sbin',
51
+ '/usr/local/bin',
52
+ '/usr/local/sbin',
53
+ process.env.PATH || '',
54
+ '/usr/bin',
55
+ '/bin',
56
+ '/usr/sbin',
57
+ '/sbin',
58
+ ].join(':'));
59
+ }
60
+
61
+ function xmlEscape(value) {
62
+ return String(value || '')
63
+ .replaceAll('&', '&')
64
+ .replaceAll('<', '&lt;')
65
+ .replaceAll('>', '&gt;');
66
+ }
67
+
68
+ function run(command, args, { stdio = 'ignore' } = {}) {
69
+ return spawnSync(command, args, { stdio, encoding: 'utf8' });
70
+ }
71
+
72
+ function runRequired(label, command, args) {
73
+ const result = run(command, args, { stdio: 'inherit' });
74
+ if (result.status !== 0) {
75
+ throw new Error(`${label} failed`);
76
+ }
77
+ }
78
+
79
+ export function getCurrentUsername() {
80
+ return process.env.USER || userInfo().username;
81
+ }
82
+
83
+ export function getSystemdLinger(username = getCurrentUsername()) {
84
+ if (!commandExists('loginctl')) return 'unknown';
85
+ const result = run('loginctl', ['show-user', username, '-p', 'Linger'], { stdio: 'pipe' });
86
+ if (result.status !== 0) return 'unknown';
87
+ const match = String(result.stdout || '').match(/^Linger=(.+)$/m);
88
+ return match ? match[1].trim() : 'unknown';
89
+ }
90
+
91
+ function getCliScriptPath() {
92
+ const scriptPath = process.argv[1] ? resolve(process.argv[1]) : '';
93
+ if (!scriptPath || !existsSync(scriptPath)) {
94
+ throw new Error('could not resolve current ticlawk CLI path');
95
+ }
96
+ return realpathSync(scriptPath);
97
+ }
98
+
99
+ function getCliLaunchArgs() {
100
+ return [process.execPath, getCliScriptPath(), 'start'];
101
+ }
102
+
103
+ function systemdQuote(value) {
104
+ const raw = String(value || '');
105
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(raw)) return raw;
106
+ return `"${raw.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
107
+ }
108
+
109
+ function installSystemd() {
110
+ if (!commandExists('systemctl')) {
111
+ throw new Error('missing required command: systemctl');
112
+ }
113
+
114
+ const svcDir = join(homedir(), '.config', 'systemd', 'user');
115
+ const svcFile = join(svcDir, 'ticlawk.service');
116
+ mkdirSync(svcDir, { recursive: true });
117
+
118
+ writeFileSync(svcFile, `[Unit]
119
+ Description=Ticlawk
120
+ After=network-online.target
121
+ Wants=network-online.target
122
+
123
+ [Service]
124
+ Type=simple
125
+ WorkingDirectory=${getPackageRoot()}
126
+ EnvironmentFile=-${AF_CONFIG_PATH}
127
+ Environment="PATH=${buildServicePath()}"
128
+ ExecStart=${getCliLaunchArgs().map(systemdQuote).join(' ')}
129
+ Restart=always
130
+ RestartSec=5
131
+ StandardOutput=append:${AF_LOG_PATH}
132
+ StandardError=append:${AF_LOG_PATH}
133
+
134
+ [Install]
135
+ WantedBy=default.target
136
+ `);
137
+
138
+ runRequired('systemd daemon-reload', 'systemctl', ['--user', 'daemon-reload']);
139
+ run('systemctl', ['--user', 'stop', 'agent-freeway']);
140
+ run('systemctl', ['--user', 'disable', 'agent-freeway']);
141
+ runRequired('systemd enable', 'systemctl', ['--user', 'enable', 'ticlawk']);
142
+
143
+ if (run('systemctl', ['--user', 'is-active', '--quiet', 'ticlawk']).status === 0) {
144
+ runRequired('systemd restart', 'systemctl', ['--user', 'restart', 'ticlawk']);
145
+ console.log(`systemd service restarted: ${svcFile}`);
146
+ } else {
147
+ runRequired('systemd start', 'systemctl', ['--user', 'start', 'ticlawk']);
148
+ console.log(`systemd service started: ${svcFile}`);
149
+ }
150
+
151
+ }
152
+
153
+ function installLaunchd() {
154
+ if (!commandExists('launchctl')) {
155
+ throw new Error('missing required command: launchctl');
156
+ }
157
+
158
+ const plistDir = join(homedir(), 'Library', 'LaunchAgents');
159
+ const legacyLabels = ['agent-freeway', 'com.ticlawk.agent-freeway'];
160
+ const launchdLabel = 'ticlawk';
161
+ const plistFile = join(plistDir, `${launchdLabel}.plist`);
162
+ const uid = String(process.getuid?.() || '');
163
+ const launchdDomain = `gui/${uid}`;
164
+ const launchdService = `${launchdDomain}/${launchdLabel}`;
165
+ mkdirSync(plistDir, { recursive: true });
166
+
167
+ writeFileSync(plistFile, `<?xml version="1.0" encoding="UTF-8"?>
168
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
169
+ <plist version="1.0">
170
+ <dict>
171
+ <key>Label</key>
172
+ <string>${launchdLabel}</string>
173
+ <key>ProgramArguments</key>
174
+ <array>
175
+ <string>${xmlEscape(process.execPath)}</string>
176
+ <string>${xmlEscape(getCliScriptPath())}</string>
177
+ <string>start</string>
178
+ </array>
179
+ <key>WorkingDirectory</key>
180
+ <string>${xmlEscape(getPackageRoot())}</string>
181
+ <key>RunAtLoad</key>
182
+ <true/>
183
+ <key>KeepAlive</key>
184
+ <true/>
185
+ <key>StandardOutPath</key>
186
+ <string>${xmlEscape(AF_LOG_PATH)}</string>
187
+ <key>StandardErrorPath</key>
188
+ <string>${xmlEscape(AF_LOG_PATH)}</string>
189
+ <key>EnvironmentVariables</key>
190
+ <dict>
191
+ <key>PATH</key>
192
+ <string>${xmlEscape(buildServicePath())}</string>
193
+ </dict>
194
+ </dict>
195
+ </plist>
196
+ `);
197
+
198
+ if (commandExists('plutil')) {
199
+ runRequired('launchd plist validation', 'plutil', ['-lint', plistFile]);
200
+ }
201
+
202
+ for (const legacyLabel of legacyLabels) {
203
+ run('launchctl', ['bootout', `${launchdDomain}/${legacyLabel}`]);
204
+ }
205
+ if (run('launchctl', ['print', launchdService]).status === 0) {
206
+ runRequired('launchd kickstart', 'launchctl', ['kickstart', '-k', launchdService]);
207
+ console.log(`launchd service restarted: ${launchdService}`);
208
+ } else {
209
+ runRequired('launchd bootstrap', 'launchctl', ['bootstrap', launchdDomain, plistFile]);
210
+ runRequired('launchd enable', 'launchctl', ['enable', launchdService]);
211
+ runRequired('launchd kickstart', 'launchctl', ['kickstart', '-k', launchdService]);
212
+ console.log(`launchd service installed: ${plistFile}`);
213
+ }
214
+ }
215
+
216
+ export function getInstallDaemonHelp() {
217
+ return `ticlawk install-daemon
218
+
219
+ Install or refresh the background daemon for the current ticlawk package.
220
+
221
+ Usage:
222
+ ticlawk install-daemon
223
+ `;
224
+ }
225
+
226
+ export async function installDaemon() {
227
+ if (process.platform === 'linux') {
228
+ installSystemd();
229
+ return true;
230
+ }
231
+
232
+ if (process.platform === 'darwin') {
233
+ installLaunchd();
234
+ return true;
235
+ }
236
+
237
+ console.log(`No daemon installer is available for ${process.platform}.`);
238
+ console.log('Run manually: ticlawk start');
239
+ return false;
240
+ }
241
+
242
+ export async function runInstallDaemon() {
243
+ await installDaemon();
244
+ const { runSetupReadiness } = await import('./setup-readiness.mjs');
245
+ await runSetupReadiness({ phase: 'daemon', ensureDaemon: false });
246
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Process-level crash diagnostics.
3
+ *
4
+ * Installs listeners for unhandledRejection / uncaughtException / SIGINT /
5
+ * SIGTERM / exit and appends a structured payload to
6
+ * ~/.ticlawk/ticlawk-crash.log so a dead daemon leaves a forensic trail.
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from 'node:fs';
10
+ import { AF_HOME, AF_CRASH_LOG_PATH } from './config.mjs';
11
+
12
+ let diagnosticsInstalled = false;
13
+
14
+ function formatDiagnosticValue(value) {
15
+ if (value instanceof Error) {
16
+ return value.stack || `${value.name}: ${value.message}`;
17
+ }
18
+ if (typeof value === 'string') return value;
19
+ try {
20
+ return JSON.stringify(value, null, 2);
21
+ } catch {
22
+ return String(value);
23
+ }
24
+ }
25
+
26
+ function writeExitDiagnostic(kind, value) {
27
+ const lines = [
28
+ `[${new Date().toISOString()}] ${kind}`,
29
+ `pid=${process.pid}`,
30
+ ];
31
+
32
+ if (value !== undefined) {
33
+ lines.push(formatDiagnosticValue(value));
34
+ }
35
+
36
+ const payload = `${lines.join('\n')}\n\n`;
37
+
38
+ try {
39
+ mkdirSync(AF_HOME, { recursive: true });
40
+ appendFileSync(AF_CRASH_LOG_PATH, payload, 'utf8');
41
+ } catch {
42
+ // Best-effort logging only.
43
+ }
44
+
45
+ try {
46
+ process.stderr.write(payload);
47
+ } catch {
48
+ // Ignore stderr failures during shutdown.
49
+ }
50
+ }
51
+
52
+ export function installProcessDiagnostics() {
53
+ if (diagnosticsInstalled) return;
54
+ diagnosticsInstalled = true;
55
+
56
+ process.on('unhandledRejection', (reason) => {
57
+ writeExitDiagnostic('unhandledRejection', reason);
58
+ process.exit(1);
59
+ });
60
+
61
+ process.on('uncaughtException', (err) => {
62
+ writeExitDiagnostic('uncaughtException', err);
63
+ process.exit(1);
64
+ });
65
+
66
+ process.on('SIGINT', () => {
67
+ writeExitDiagnostic('signal SIGINT');
68
+ process.exit(0);
69
+ });
70
+
71
+ process.on('SIGTERM', () => {
72
+ writeExitDiagnostic('signal SIGTERM');
73
+ process.exit(0);
74
+ });
75
+
76
+ process.on('exit', (code) => {
77
+ writeExitDiagnostic(`exit diagnostic code=${code}`);
78
+ });
79
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const workerEventSeq = new Map();
4
+ const DELTA_LOG_PREVIEW_CHARS = 48;
5
+
6
+ function shortId(value) {
7
+ return value ? String(value).slice(0, 8) : null;
8
+ }
9
+
10
+ function hashText(text) {
11
+ return createHash('sha1').update(String(text)).digest('hex').slice(0, 12);
12
+ }
13
+
14
+ function previewDelta(text) {
15
+ const normalized = String(text).replace(/\n/g, '\\n');
16
+ if (normalized.length <= DELTA_LOG_PREVIEW_CHARS) return normalized;
17
+ return normalized.slice(0, DELTA_LOG_PREVIEW_CHARS) + '…';
18
+ }
19
+
20
+ function nextWorkerEventMeta({ agent, sessionId, turnId }) {
21
+ const key = `${agent}:${sessionId || ''}:${turnId || 'session'}`;
22
+ const seq = (workerEventSeq.get(key) || 0) + 1;
23
+ workerEventSeq.set(key, seq);
24
+ return {
25
+ key,
26
+ seq,
27
+ originTs: new Date().toISOString(),
28
+ };
29
+ }
30
+
31
+ function clearWorkerEventMeta(key) {
32
+ if (!key) return;
33
+ workerEventSeq.delete(key);
34
+ }
35
+
36
+ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd = '', turnId = null, replyToMessageId = null, event, logger }) {
37
+ if (!sessionId || typeof adapter.emitEvent !== 'function') return;
38
+ const eventName = event?.worker_event_name || event?.hook_event_name || 'unknown';
39
+ const logicalTurnId = replyToMessageId || turnId || event?.turn_id || null;
40
+ const { key, seq, originTs } = nextWorkerEventMeta({ agent, sessionId, turnId: logicalTurnId });
41
+ const enrichedEvent = {
42
+ ...event,
43
+ turn_id: logicalTurnId,
44
+ reply_to_message_id: replyToMessageId || null,
45
+ event_seq: seq,
46
+ origin_ts: originTs,
47
+ };
48
+ if (typeof event?.delta === 'string' && event.delta) {
49
+ enrichedEvent.delta_chars = event.delta.length;
50
+ enrichedEvent.delta_hash = hashText(event.delta);
51
+ logger?.debugLog?.('events', 'delta.recv', {
52
+ bindingId: binding.id,
53
+ agent,
54
+ sessionId: shortId(sessionId),
55
+ turnId: shortId(logicalTurnId),
56
+ seq,
57
+ chars: event.delta.length,
58
+ hash: enrichedEvent.delta_hash,
59
+ preview: previewDelta(event.delta),
60
+ });
61
+ } else {
62
+ logger?.debugLog?.('events', 'event.recv', {
63
+ bindingId: binding.id,
64
+ agent,
65
+ sessionId: shortId(sessionId),
66
+ turnId: shortId(logicalTurnId),
67
+ seq,
68
+ eventName,
69
+ });
70
+ }
71
+ await adapter.emitEvent(binding, {
72
+ agent,
73
+ sessionId,
74
+ cwd,
75
+ event: enrichedEvent,
76
+ });
77
+ if (eventName === 'worker.turn.complete' || eventName === 'worker.turn.error') {
78
+ clearWorkerEventMeta(key);
79
+ }
80
+ }
@@ -0,0 +1,106 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter } from 'node:path';
5
+ import { isAbsolute, join } from 'node:path';
6
+ import { buildRuntimeEnv } from './runtime-env.mjs';
7
+
8
+ export const COMMON_EXECUTABLE_DIRS = [
9
+ `${homedir()}/.local/bin`,
10
+ `${homedir()}/.npm-global/bin`,
11
+ `${homedir()}/.bun/bin`,
12
+ `${homedir()}/.cargo/bin`,
13
+ '/opt/homebrew/bin',
14
+ '/opt/homebrew/sbin',
15
+ '/usr/local/bin',
16
+ '/usr/local/sbin',
17
+ '/usr/bin',
18
+ '/bin',
19
+ '/usr/sbin',
20
+ '/sbin',
21
+ ];
22
+
23
+ export function isExecutablePath(filePath) {
24
+ if (!filePath || !isAbsolute(filePath)) return false;
25
+ try {
26
+ accessSync(filePath, constants.X_OK);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function buildLookupPath(extraDirs = []) {
34
+ const seen = new Set();
35
+ return [
36
+ ...String(process.env.PATH || '').split(delimiter),
37
+ ...extraDirs,
38
+ ...COMMON_EXECUTABLE_DIRS,
39
+ ]
40
+ .map(part => String(part || '').trim())
41
+ .filter(Boolean)
42
+ .filter((part) => {
43
+ if (seen.has(part)) return false;
44
+ seen.add(part);
45
+ return true;
46
+ })
47
+ .join(delimiter);
48
+ }
49
+
50
+ function commandFromPath(command, extraDirs = []) {
51
+ if (!command || String(command).includes('/')) return null;
52
+ const result = spawnSync('sh', ['-c', 'command -v "$1"', 'sh', String(command)], {
53
+ env: buildRuntimeEnv({ PATH: buildLookupPath(extraDirs) }),
54
+ encoding: 'utf8',
55
+ });
56
+ if (result.status !== 0) return null;
57
+ const resolved = String(result.stdout || '').split('\n').map(line => line.trim()).find(Boolean);
58
+ return isExecutablePath(resolved) ? resolved : null;
59
+ }
60
+
61
+ export function resolveExecutable({
62
+ command,
63
+ preferredPath = null,
64
+ envKey = null,
65
+ configuredPath = null,
66
+ extraDirs = [],
67
+ } = {}) {
68
+ const candidates = [
69
+ preferredPath,
70
+ configuredPath,
71
+ envKey ? process.env[envKey] : null,
72
+ command,
73
+ ].map(value => String(value || '').trim()).filter(Boolean);
74
+
75
+ for (const candidate of candidates) {
76
+ if (isAbsolute(candidate) && isExecutablePath(candidate)) {
77
+ return candidate;
78
+ }
79
+ const fromPath = commandFromPath(candidate, extraDirs);
80
+ if (fromPath) return fromPath;
81
+ }
82
+
83
+ for (const dir of extraDirs) {
84
+ const candidate = join(dir, command);
85
+ if (isExecutablePath(candidate)) return candidate;
86
+ }
87
+
88
+ for (const dir of COMMON_EXECUTABLE_DIRS) {
89
+ const candidate = join(dir, command);
90
+ if (isExecutablePath(candidate)) return candidate;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ export function getExecutableVersion(executablePath, args = ['--version']) {
97
+ if (!executablePath) return null;
98
+ const result = spawnSync(executablePath, args, {
99
+ env: buildRuntimeEnv(),
100
+ encoding: 'utf8',
101
+ timeout: 5000,
102
+ });
103
+ if (result.error || result.status !== 0) return null;
104
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
105
+ return output.split('\n').map(line => line.trim()).find(Boolean) || null;
106
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ import { AF_HOME } from './config.mjs';
6
+
7
+ const HOST_ID_PATH = join(AF_HOME, 'host-id');
8
+
9
+ let cachedHostId = null;
10
+
11
+ export function getHostId() {
12
+ if (cachedHostId) return cachedHostId;
13
+
14
+ try {
15
+ if (existsSync(HOST_ID_PATH)) {
16
+ const current = readFileSync(HOST_ID_PATH, 'utf8').trim();
17
+ if (current) {
18
+ cachedHostId = current;
19
+ return cachedHostId;
20
+ }
21
+ }
22
+ } catch {}
23
+
24
+ const next = `afh_${randomUUID()}`;
25
+ try {
26
+ mkdirSync(dirname(HOST_ID_PATH), { recursive: true });
27
+ writeFileSync(HOST_ID_PATH, `${next}\n`, { mode: 0o600 });
28
+ } catch {}
29
+ cachedHostId = next;
30
+ return cachedHostId;
31
+ }
32
+
33
+ export function getHostLabel() {
34
+ return hostname();
35
+ }
36
+
37
+ export function getBindingRuntimeHostId(binding) {
38
+ return String(
39
+ binding?.runtime_host_id
40
+ || binding?.targetMeta?.runtime_host_id
41
+ || ''
42
+ ).trim();
43
+ }
44
+
45
+ export function belongsToRuntimeHost(binding, hostId = getHostId()) {
46
+ const bindingHostId = getBindingRuntimeHostId(binding);
47
+ return !bindingHostId || bindingHostId === hostId;
48
+ }
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ function writeJson(res, statusCode, body) {
4
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
5
+ res.end(JSON.stringify(body));
6
+ }
7
+
8
+ async function readBody(req) {
9
+ let body = '';
10
+ for await (const chunk of req) {
11
+ body += chunk;
12
+ }
13
+ return body;
14
+ }
15
+
16
+ export function startLocalHttpServer({ port, adapter }) {
17
+ const server = createServer(async (req, res) => {
18
+ res.setHeader('Access-Control-Allow-Origin', '*');
19
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
20
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
21
+ if (req.method === 'OPTIONS') {
22
+ res.writeHead(204);
23
+ res.end();
24
+ return;
25
+ }
26
+
27
+ if (req.method === 'GET' && req.url === '/health') {
28
+ try {
29
+ const health = await adapter.health();
30
+ writeJson(res, 200, {
31
+ ok: true,
32
+ adapter: adapter.id,
33
+ ...health,
34
+ });
35
+ } catch (err) {
36
+ writeJson(res, 500, {
37
+ ok: false,
38
+ adapter: adapter.id,
39
+ error: err?.message || 'health check failed',
40
+ });
41
+ }
42
+ return;
43
+ }
44
+
45
+ writeJson(res, 404, { error: 'not found' });
46
+ });
47
+
48
+ server.on('error', (err) => {
49
+ if (err.code === 'EADDRINUSE') {
50
+ console.error(`ticlawk is already running on port ${port}.`);
51
+ console.error('Check with: ticlawk health');
52
+ console.error(`If it is not, free the port (lsof -i :${port}) or set FEED_RELAY_PORT to a different port.`);
53
+ process.exit(1);
54
+ }
55
+ throw err;
56
+ });
57
+
58
+ server.listen(port, () => {
59
+ console.log(`[relay] HTTP server listening on :${port}`);
60
+ console.log(`[relay] adapter: ${adapter.id}`);
61
+ console.log('[relay] GET /health - daemon status check');
62
+ });
63
+
64
+ return server;
65
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bus-level structured logging helpers.
3
+ *
4
+ * Pure utility module with no dependencies on other core modules. Used by
5
+ * runtimes, adapters, and the bus itself.
6
+ */
7
+
8
+ export function isoNow() {
9
+ return new Date().toISOString();
10
+ }
11
+
12
+ export function parseTimeMs(value) {
13
+ if (!value) return null;
14
+ if (typeof value === 'number') return value > 1e12 ? value : null;
15
+ const ms = Date.parse(String(value));
16
+ return Number.isFinite(ms) ? ms : null;
17
+ }
18
+
19
+ export function formatLogMeta(meta = {}) {
20
+ return Object.entries(meta)
21
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
22
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
23
+ .join(' ');
24
+ }
25
+
26
+ export function debugLog(scope, message, meta = {}) {
27
+ const suffix = formatLogMeta(meta);
28
+ console.log(`[${isoNow()}] [${scope}] ${message}${suffix ? ` ${suffix}` : ''}`);
29
+ }
30
+
31
+ export function debugError(scope, message, meta = {}) {
32
+ const suffix = formatLogMeta(meta);
33
+ console.error(`[${isoNow()}] [${scope}] ${message}${suffix ? ` ${suffix}` : ''}`);
34
+ }