ticlawk 0.1.15 → 0.1.16-dev.2

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.
@@ -4,8 +4,10 @@
4
4
  * Wraps the pi CLI RPC mode and exposes the ticlawk runtime contract.
5
5
  */
6
6
 
7
- import { existsSync } from 'node:fs';
8
7
  import { basename } from 'node:path';
8
+ import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
9
+ import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
10
+ import { ensureAgentHome } from '../../core/agent-home.mjs';
9
11
  import {
10
12
  buildPiImagesFromInbound,
11
13
  discoverPiSessions,
@@ -21,7 +23,7 @@ import {
21
23
  shouldStreamRuntime,
22
24
  createDeltaAggregator,
23
25
  sendAdapterMessage,
24
- sendResult,
26
+ recordActivity,
25
27
  reportSubprocessFailure,
26
28
  terminalRuntimeFailure,
27
29
  updateBindingRuntimeMeta,
@@ -30,24 +32,28 @@ import {
30
32
  export const piRuntime = {
31
33
  name: 'pi',
32
34
 
33
- runTurn({ sessionId, cwd, piPath }, text, opts = {}) {
35
+ runTurn({ sessionId, cwd, piPath, agentEnv }, text, opts = {}) {
34
36
  return runPiPrompt({
35
37
  sessionId,
36
38
  cwd,
37
39
  message: text,
38
40
  images: opts.images,
39
41
  piPath,
42
+ agentEnv,
43
+ standingPrompt: opts.standingPrompt || null,
40
44
  timeoutMs: opts.timeoutMs,
41
45
  });
42
46
  },
43
47
 
44
- runTurnStream({ sessionId, cwd, piPath }, text, opts = {}) {
48
+ runTurnStream({ sessionId, cwd, piPath, agentEnv }, text, opts = {}) {
45
49
  return runPiPrompt({
46
50
  sessionId,
47
51
  cwd,
48
52
  message: text,
49
53
  images: opts.images,
50
54
  piPath,
55
+ agentEnv,
56
+ standingPrompt: opts.standingPrompt || null,
51
57
  timeoutMs: opts.timeoutMs,
52
58
  onEvent: opts.onEvent,
53
59
  });
@@ -74,8 +80,6 @@ export const piRuntime = {
74
80
  displayName: payload.name || basename(session.cwd || requestedCwd) || 'pi',
75
81
  runtimeMeta: {
76
82
  sessionId: session.sessionId,
77
- workdir: session.cwd || requestedCwd,
78
- cwd: session.cwd || requestedCwd,
79
83
  path: session.path || null,
80
84
  runtimePath: piPath,
81
85
  piPath,
@@ -85,20 +89,11 @@ export const piRuntime = {
85
89
  };
86
90
  }
87
91
 
88
- if (!requestedCwd) {
89
- throw new Error('cwd or sessionId is required for pi binding');
90
- }
91
- if (!existsSync(requestedCwd)) {
92
- throw new Error(`pi cwd not found locally: ${requestedCwd}`);
93
- }
94
-
95
92
  return {
96
93
  runtime: this.name,
97
- displayName: payload.name || basename(requestedCwd) || 'pi',
94
+ displayName: payload.name || (requestedCwd ? basename(requestedCwd) : 'pi'),
98
95
  runtimeMeta: {
99
96
  sessionId: null,
100
- workdir: requestedCwd,
101
- cwd: requestedCwd,
102
97
  path: null,
103
98
  runtimePath: piPath,
104
99
  piPath,
@@ -117,16 +112,9 @@ export const piRuntime = {
117
112
  if (!binding) return false;
118
113
  const adapter = ctx.adapter;
119
114
  const meta = binding.runtimeMeta || {};
120
-
121
- if (!meta.cwd || !existsSync(meta.cwd)) {
122
- await sendAdapterMessage(adapter, binding, {
123
- type: 'assistant',
124
- text: `⚠️ pi cwd not found: ${meta.cwd || '(missing)'}`,
125
- media: [],
126
- replyToMessageId: inbound.messageId || null,
127
- });
128
- return true;
129
- }
115
+ const agentHome = ensureAgentHome(binding.id, {
116
+ displayName: binding.display_name || binding.name || null,
117
+ });
130
118
 
131
119
  let images = [];
132
120
  let message = inbound.text || '';
@@ -161,7 +149,7 @@ export const piRuntime = {
161
149
  binding,
162
150
  agent: this.name,
163
151
  sessionId: sessionId || meta.sessionId || binding.id,
164
- cwd: cwd || meta.cwd,
152
+ cwd: cwd || agentHome,
165
153
  replyToMessageId: inbound.messageId || null,
166
154
  event: {
167
155
  hook_event_name: 'worker.message.delta',
@@ -176,8 +164,15 @@ export const piRuntime = {
176
164
  try {
177
165
  const runtimePiPath = requirePiPath(meta.piPath || meta.runtimePath);
178
166
  const runtimePiVersion = getPiRuntimeHealth(runtimePiPath).version || meta.piVersion || null;
167
+ const agentEnv = buildAgentRuntimeEnv({
168
+ agentId: binding.id,
169
+ sessionId: meta.sessionId,
170
+ hostId: binding.runtime_host_id,
171
+ });
172
+ const standingPrompt = buildStandingPrompt({ agentId: binding.id });
179
173
  const result = shouldStreamRuntime(this.name, this)
180
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath }, message, {
174
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, {
175
+ standingPrompt,
181
176
  images,
182
177
  onEvent: async (event) => {
183
178
  if (event?.type === 'turn.started') {
@@ -186,7 +181,7 @@ export const piRuntime = {
186
181
  binding,
187
182
  agent: this.name,
188
183
  sessionId: event.sessionId || meta.sessionId || binding.id,
189
- cwd: meta.cwd,
184
+ cwd: agentHome,
190
185
  replyToMessageId: inbound.messageId || null,
191
186
  event: {
192
187
  hook_event_name: 'worker.turn.start',
@@ -197,17 +192,16 @@ export const piRuntime = {
197
192
  } else if (event?.type === 'message.delta' && event.text) {
198
193
  deltaAggregator.push(event.text, {
199
194
  sessionId: event.sessionId || meta.sessionId || binding.id,
200
- cwd: meta.cwd,
195
+ cwd: agentHome,
201
196
  });
202
197
  }
203
198
  },
204
199
  })
205
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath }, message, { images });
200
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
206
201
 
207
202
  await deltaAggregator.flush();
208
203
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
209
204
  sessionId: result?.sessionId || meta.sessionId,
210
- cwd: result?.cwd || meta.cwd,
211
205
  path: result?.path || meta.path || null,
212
206
  runtimePath: runtimePiPath,
213
207
  piPath: runtimePiPath,
@@ -215,13 +209,13 @@ export const piRuntime = {
215
209
  rotatePending: false,
216
210
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
217
211
  }, { status: 'connected' });
218
- await sendResult(adapter, nextBinding, inbound, result);
212
+ await recordActivity(adapter, nextBinding, inbound, result);
219
213
  await emitWorkerEvent({
220
214
  adapter,
221
215
  binding: nextBinding,
222
216
  agent: this.name,
223
217
  sessionId: result?.sessionId || meta.sessionId || binding.id,
224
- cwd: result?.cwd || meta.cwd,
218
+ cwd: result?.cwd || agentHome,
225
219
  replyToMessageId: inbound.messageId || null,
226
220
  event: {
227
221
  hook_event_name: 'Stop',
@@ -237,7 +231,7 @@ export const piRuntime = {
237
231
  binding,
238
232
  agent: this.name,
239
233
  sessionId: meta.sessionId || binding.id,
240
- cwd: meta.cwd,
234
+ cwd: agentHome,
241
235
  replyToMessageId: inbound.messageId || null,
242
236
  event: {
243
237
  hook_event_name: 'worker.turn.error',
@@ -269,7 +263,7 @@ export const piRuntime = {
269
263
  binding,
270
264
  agent: this.name,
271
265
  sessionId: meta.sessionId || binding.id,
272
- cwd: meta.cwd || '',
266
+ cwd: ensureAgentHome(binding.id) || '',
273
267
  event: {
274
268
  hook_event_name: 'Stop',
275
269
  worker_event_name: 'worker.turn.complete',
@@ -195,15 +195,21 @@ export function runPiPrompt({
195
195
  message,
196
196
  images = [],
197
197
  piPath = null,
198
+ agentEnv = null,
199
+ standingPrompt = null,
198
200
  timeoutMs = Number(process.env.PI_RUN_TIMEOUT_MS || DEFAULT_PI_RUN_TIMEOUT_MS),
199
201
  onEvent,
200
202
  }) {
203
+ // pi has no documented system-prompt flag — prepend on first turn.
204
+ const finalMessage = standingPrompt && !sessionId
205
+ ? `${standingPrompt}\n\n---\n\n${message}`
206
+ : message;
201
207
  return new Promise((resolve, reject) => {
202
208
  const startedAt = Date.now();
203
209
  const piCommand = requirePiPath(piPath);
204
210
  const child = spawn(piCommand, buildPiRpcArgs({ sessionId }), {
205
211
  cwd,
206
- env: buildRuntimeEnv(),
212
+ env: buildRuntimeEnv(agentEnv || {}),
207
213
  stdio: ['pipe', 'pipe', 'pipe'],
208
214
  });
209
215
 
@@ -349,7 +355,7 @@ export function runPiPrompt({
349
355
 
350
356
  (async () => {
351
357
  try {
352
- await send({ type: 'prompt', message, images });
358
+ await send({ type: 'prompt', message: finalMessage, images });
353
359
  await completion;
354
360
  const state = await send({ type: 'get_state' }).catch(() => null);
355
361
  const lastAssistant = await send({ type: 'get_last_assistant_text' }).catch(() => null);
package/ticlawk.mjs CHANGED
@@ -17,11 +17,11 @@ import {
17
17
  AF_CONFIG_PATH,
18
18
  AF_LOG_PATH,
19
19
  AF_CRASH_LOG_PATH,
20
- getConfiguredAdapter,
21
20
  loadPersistentConfig,
22
21
  persistConfig,
23
22
  } from './src/core/config.mjs';
24
23
  import { startLocalHttpServer } from './src/core/http.mjs';
24
+ import { startReminderTicker } from './src/core/reminder-ticker.mjs';
25
25
  import { installProcessDiagnostics } from './src/core/diagnostics.mjs';
26
26
  import * as logger from './src/core/logger.mjs';
27
27
  import { Bus } from './src/core/bus.mjs';
@@ -31,6 +31,28 @@ import { buildRuntimeContext, normalizeServiceType } from './src/core/runtime-re
31
31
  import { belongsToRuntimeHost, getBindingRuntimeHostId, getHostId } from './src/core/host-id.mjs';
32
32
  import { readPkgVersion } from './src/core/update.mjs';
33
33
  import { buildImageMessageFromInbound } from './src/core/media/inbound.mjs';
34
+ import { existsSync as _fsExists, unlinkSync as _fsUnlink } from 'node:fs';
35
+ import { join as _pathJoin } from 'node:path';
36
+ import { getActiveProfile } from './src/core/profiles.mjs';
37
+
38
+ // Pre-profile builds wrote bindings to ~/.ticlawk/bindings.json. The
39
+ // profile flow now owns binding persistence under
40
+ // ~/.ticlawk/profiles/<adapter>/<userId>/bindings.json. The root file
41
+ // hasn't had a writer for a while but lingers as stale state. Prune
42
+ // once per daemon start so it doesn't confuse `jq` audits later.
43
+ function pruneLegacyRootBindings() {
44
+ if (!getActiveProfile()) return;
45
+ const rootPath = _pathJoin(AF_HOME, 'bindings.json');
46
+ if (!_fsExists(rootPath)) return;
47
+ try {
48
+ _fsUnlink(rootPath);
49
+ logger.debugLog?.('startup', 'legacy-root-bindings.pruned', { path: rootPath });
50
+ } catch (err) {
51
+ logger.debugError?.('startup', 'legacy-root-bindings.prune-failed', {
52
+ path: rootPath, error: err?.message || String(err),
53
+ });
54
+ }
55
+ }
34
56
 
35
57
  // Re-export the config-owned paths so local tooling can inspect the
36
58
  // ticlawk home/config/log locations without reaching into src/.
@@ -81,7 +103,7 @@ function createUpsertBindingWithSync(runtimes, adapter) {
81
103
  };
82
104
  }
83
105
 
84
- function createCacheBinding(runtimes, getAdapter) {
106
+ function createPersistBinding(runtimes, getAdapter) {
85
107
  return async (binding) => {
86
108
  const nextBinding = await upsertBinding(binding);
87
109
  if (!belongsToRuntimeHost(nextBinding)) {
@@ -102,13 +124,13 @@ function createCacheBinding(runtimes, getAdapter) {
102
124
  };
103
125
  }
104
126
 
105
- function createBaseRuntimeCtx(runtimes, cacheBinding, upsertBindingWithSync) {
127
+ function createBaseRuntimeCtx(runtimes, persistBinding, upsertBindingWithSync) {
106
128
  return {
107
129
  runtimes,
108
130
  getBinding,
109
131
  listBindings,
110
132
  deleteBinding,
111
- cacheBinding,
133
+ persistBinding,
112
134
  upsertBinding: upsertBindingWithSync,
113
135
  buildImageMessageFromInbound,
114
136
  logger,
@@ -222,12 +244,12 @@ export async function startTiclawk() {
222
244
  if (started) return;
223
245
  started = true;
224
246
  installProcessDiagnostics();
247
+ pruneLegacyRootBindings();
225
248
 
226
249
  const { runtimeList, runtimes } = await buildRuntimeContext();
227
250
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
228
- const configuredAdapter = getConfiguredAdapter();
229
251
  let adapter;
230
- const cacheBinding = createCacheBinding(runtimes, () => adapter);
252
+ const persistBinding = createPersistBinding(runtimes, () => adapter);
231
253
  let baseRuntimeCtx;
232
254
  let syncBinding = async (binding) => {
233
255
  if (!adapter) {
@@ -235,13 +257,13 @@ export async function startTiclawk() {
235
257
  }
236
258
  return upsertBinding(binding);
237
259
  };
238
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
260
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
239
261
  adapter = createAdapter(
240
- configuredAdapter,
262
+ 'ticlawk',
241
263
  createAdapterContext(baseRuntimeCtx, resolveRuntimeBinding)
242
264
  );
243
265
  syncBinding = createUpsertBindingWithSync(runtimes, adapter);
244
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
266
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
245
267
 
246
268
  printBanner(adapter);
247
269
  if (typeof adapter.refreshBindings === 'function') {
@@ -249,7 +271,12 @@ export async function startTiclawk() {
249
271
  }
250
272
  registerRuntimeHandlers(runtimeList, baseRuntimeCtx, adapter);
251
273
  await replayBindings(runtimes, adapter);
252
- startLocalHttpServer({ port: HTTP_PORT, adapter });
274
+ startLocalHttpServer({
275
+ port: HTTP_PORT,
276
+ adapter,
277
+ ctx: { listBindings, getBinding },
278
+ });
279
+ startReminderTicker();
253
280
  await recoverAllRuntimes(runtimeList, adapter);
254
281
  await reconcileBindingsAfterRestart(runtimes, adapter);
255
282
  await adapter.start();
@@ -1,137 +0,0 @@
1
- <svg width="960" height="520" viewBox="0 0 960 520" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
- <title id="title">ticlawk connects any client to any agent</title>
3
- <desc id="desc">A hub-and-spoke diagram with clients on the left, ticlawk in the center, and agent runtimes on the right.</desc>
4
-
5
- <defs>
6
- <linearGradient id="hub" x1="372" y1="162" x2="588" y2="378" gradientUnits="userSpaceOnUse">
7
- <stop stop-color="#1D4ED8"/>
8
- <stop offset="0.52" stop-color="#0F766E"/>
9
- <stop offset="1" stop-color="#7C2D12"/>
10
- </linearGradient>
11
- <linearGradient id="clientLine" x1="180" y1="72" x2="460" y2="260" gradientUnits="userSpaceOnUse">
12
- <stop stop-color="#2563EB"/>
13
- <stop offset="1" stop-color="#0F766E"/>
14
- </linearGradient>
15
- <linearGradient id="agentLine" x1="500" y1="260" x2="780" y2="448" gradientUnits="userSpaceOnUse">
16
- <stop stop-color="#0F766E"/>
17
- <stop offset="1" stop-color="#C2410C"/>
18
- </linearGradient>
19
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
20
- <feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#0F172A" flood-opacity="0.12"/>
21
- </filter>
22
- <style>
23
- .label { fill: #0f172a; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 700; font-size: 16px; }
24
- .small { fill: #475569; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 600; font-size: 13px; }
25
- .hubText { fill: white; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 800; font-size: 27px; }
26
- .hubSub { fill: #E0F2FE; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 650; font-size: 13px; letter-spacing: 0.08em; }
27
- .caption { fill: #334155; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 650; font-size: 14px; }
28
- .node { fill: #FFFFFF; stroke: #CBD5E1; stroke-width: 1.4; }
29
- .dot { fill: #F8FAFC; stroke: #CBD5E1; stroke-width: 1.4; }
30
- </style>
31
- </defs>
32
-
33
- <rect width="960" height="520" rx="22" fill="#F8FAFC"/>
34
- <rect x="24" y="24" width="912" height="472" rx="18" fill="#FFFFFF" stroke="#E2E8F0"/>
35
-
36
- <text x="180" y="64" text-anchor="middle" class="caption">Any client surface</text>
37
- <text x="780" y="64" text-anchor="middle" class="caption">Any agent runtime</text>
38
-
39
- <path d="M230 116 C310 126 350 166 405 215" stroke="url(#clientLine)" stroke-width="4" stroke-linecap="round"/>
40
- <path d="M230 188 C310 192 354 216 405 244" stroke="url(#clientLine)" stroke-width="4" stroke-linecap="round"/>
41
- <path d="M230 260 C306 260 348 260 405 260" stroke="url(#clientLine)" stroke-width="4" stroke-linecap="round"/>
42
- <path d="M230 332 C310 328 354 304 405 276" stroke="url(#clientLine)" stroke-width="4" stroke-linecap="round"/>
43
- <path d="M230 404 C310 394 350 354 405 305" stroke="url(#clientLine)" stroke-width="4" stroke-linecap="round"/>
44
-
45
- <path d="M555 215 C610 166 650 126 730 116" stroke="url(#agentLine)" stroke-width="4" stroke-linecap="round"/>
46
- <path d="M555 244 C606 216 650 192 730 188" stroke="url(#agentLine)" stroke-width="4" stroke-linecap="round"/>
47
- <path d="M555 260 C612 260 654 260 730 260" stroke="url(#agentLine)" stroke-width="4" stroke-linecap="round"/>
48
- <path d="M555 276 C606 304 650 328 730 332" stroke="url(#agentLine)" stroke-width="4" stroke-linecap="round"/>
49
- <path d="M555 305 C610 354 650 394 730 404" stroke="url(#agentLine)" stroke-width="4" stroke-linecap="round"/>
50
-
51
- <g filter="url(#shadow)">
52
- <rect x="70" y="88" width="160" height="56" rx="8" class="node"/>
53
- <circle cx="100" cy="116" r="14" fill="#DBEAFE"/>
54
- <path d="M94 116h12M100 110v12" stroke="#2563EB" stroke-width="2.4" stroke-linecap="round"/>
55
- <text x="124" y="112" class="label">Telegram</text>
56
- <text x="124" y="130" class="small">chat bot</text>
57
-
58
- <rect x="70" y="160" width="160" height="56" rx="8" class="node"/>
59
- <circle cx="100" cy="188" r="14" fill="#CCFBF1"/>
60
- <rect x="94" y="180" width="12" height="16" rx="3" stroke="#0F766E" stroke-width="2.2"/>
61
- <path d="M98 202h4" stroke="#0F766E" stroke-width="2.2" stroke-linecap="round"/>
62
- <text x="124" y="184" class="label">ticlawk</text>
63
- <text x="124" y="202" class="small">mobile app</text>
64
-
65
- <rect x="70" y="232" width="160" height="56" rx="8" class="node"/>
66
- <circle cx="100" cy="260" r="14" fill="#FDE68A"/>
67
- <path d="M92 256h16M92 262h16M100 252v16" stroke="#A16207" stroke-width="2" stroke-linecap="round"/>
68
- <text x="124" y="256" class="label">Web UI</text>
69
- <text x="124" y="274" class="small">browser</text>
70
-
71
- <rect x="70" y="304" width="160" height="56" rx="8" class="node"/>
72
- <circle cx="100" cy="332" r="14" fill="#EDE9FE"/>
73
- <path d="M94 326l6 6-6 6M102 338h6" stroke="#6D28D9" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
74
- <text x="124" y="328" class="label">CLI</text>
75
- <text x="124" y="346" class="small">terminal</text>
76
-
77
- <rect x="70" y="376" width="160" height="56" rx="8" class="node"/>
78
- <circle cx="100" cy="404" r="14" fill="#FFE4E6"/>
79
- <path d="M94 398h12v12H94z" stroke="#BE123C" stroke-width="2.2" stroke-linejoin="round"/>
80
- <path d="M97 395h6M97 413h6" stroke="#BE123C" stroke-width="2.2" stroke-linecap="round"/>
81
- <text x="124" y="400" class="label">Custom app</text>
82
- <text x="124" y="418" class="small">your surface</text>
83
- </g>
84
-
85
- <g filter="url(#shadow)">
86
- <rect x="730" y="88" width="160" height="56" rx="8" class="node"/>
87
- <circle cx="760" cy="116" r="14" fill="#FEF3C7"/>
88
- <path d="M753 121l7-12 7 12M756 117h8" stroke="#B45309" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
89
- <text x="784" y="112" class="label">Claude Code</text>
90
- <text x="784" y="130" class="small">project agent</text>
91
-
92
- <rect x="730" y="160" width="160" height="56" rx="8" class="node"/>
93
- <circle cx="760" cy="188" r="14" fill="#DBEAFE"/>
94
- <path d="M754 188a6 6 0 1 1 12 0a6 6 0 1 1-12 0" stroke="#1D4ED8" stroke-width="2.2"/>
95
- <path d="M760 176v4M760 196v4M748 188h4M768 188h4" stroke="#1D4ED8" stroke-width="2.2" stroke-linecap="round"/>
96
- <text x="784" y="184" class="label">Codex</text>
97
- <text x="784" y="202" class="small">coding agent</text>
98
-
99
- <rect x="730" y="232" width="160" height="56" rx="8" class="node"/>
100
- <circle cx="760" cy="260" r="14" fill="#CCFBF1"/>
101
- <path d="M752 260h16M760 252v16M755 255l10 10M765 255l-10 10" stroke="#0F766E" stroke-width="2" stroke-linecap="round"/>
102
- <text x="784" y="256" class="label">OpenClaw</text>
103
- <text x="784" y="274" class="small">local runtime</text>
104
-
105
- <rect x="730" y="304" width="160" height="56" rx="8" class="node"/>
106
- <circle cx="760" cy="332" r="14" fill="#FCE7F3"/>
107
- <path d="M752 332c3-8 13-8 16 0c-3 8-13 8-16 0Z" stroke="#BE185D" stroke-width="2.2"/>
108
- <circle cx="760" cy="332" r="3" fill="#BE185D"/>
109
- <text x="784" y="328" class="label">opencode</text>
110
- <text x="784" y="346" class="small">provider agnostic</text>
111
-
112
- <rect x="730" y="376" width="160" height="56" rx="8" class="node"/>
113
- <circle cx="760" cy="404" r="14" fill="#E0E7FF"/>
114
- <path d="M754 398h12v12h-12zM751 401h3M766 401h3M751 407h3M766 407h3" stroke="#4338CA" stroke-width="2" stroke-linejoin="round"/>
115
- <text x="784" y="400" class="label">Custom agent</text>
116
- <text x="784" y="418" class="small">your harness</text>
117
- </g>
118
-
119
- <g filter="url(#shadow)">
120
- <circle cx="480" cy="260" r="118" fill="url(#hub)"/>
121
- <circle cx="480" cy="260" r="86" fill="#FFFFFF" fill-opacity="0.12" stroke="#FFFFFF" stroke-opacity="0.32" stroke-width="1.5"/>
122
- <circle cx="480" cy="260" r="52" fill="#FFFFFF" fill-opacity="0.16" stroke="#FFFFFF" stroke-opacity="0.38" stroke-width="1.4"/>
123
-
124
- <circle cx="480" cy="176" r="8" class="dot"/>
125
- <circle cx="548" cy="212" r="8" class="dot"/>
126
- <circle cx="548" cy="308" r="8" class="dot"/>
127
- <circle cx="480" cy="344" r="8" class="dot"/>
128
- <circle cx="412" cy="308" r="8" class="dot"/>
129
- <circle cx="412" cy="212" r="8" class="dot"/>
130
- <path d="M480 184v152M420 216l120 88M540 216l-120 88" stroke="#FFFFFF" stroke-opacity="0.65" stroke-width="2.6" stroke-linecap="round"/>
131
-
132
- <text x="480" y="253" text-anchor="middle" class="hubText">ticlawk</text>
133
- <text x="480" y="281" text-anchor="middle" class="hubSub">UNIVERSAL ROUTER</text>
134
- </g>
135
-
136
- <text x="480" y="465" text-anchor="middle" class="small">Bind sessions, relay streams, keep every side replaceable.</text>
137
- </svg>