ticlawk 0.1.15 → 0.1.16-dev.10

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 (34) hide show
  1. package/README.md +96 -212
  2. package/bin/ticlawk.mjs +223 -46
  3. package/package.json +2 -5
  4. package/src/adapters/ticlawk/api.mjs +308 -43
  5. package/src/adapters/ticlawk/credentials.mjs +1 -2
  6. package/src/adapters/ticlawk/index.mjs +310 -119
  7. package/src/cli/agent-commands.mjs +876 -0
  8. package/src/core/adapter-registry.mjs +12 -28
  9. package/src/core/agent-cli-handlers.mjs +731 -0
  10. package/src/core/agent-home.mjs +85 -0
  11. package/src/core/config.mjs +0 -15
  12. package/src/core/http.mjs +211 -18
  13. package/src/core/reminder-ticker.mjs +70 -0
  14. package/src/core/runtime-contract.mjs +1 -1
  15. package/src/core/runtime-env.mjs +41 -5
  16. package/src/core/runtime-support.mjs +31 -44
  17. package/src/core/ticlawk-control.mjs +7 -6
  18. package/src/migrate/write-initial-memory.mjs +101 -0
  19. package/src/runtimes/_shared/standing-prompt.mjs +308 -0
  20. package/src/runtimes/claude-code/index.mjs +49 -133
  21. package/src/runtimes/claude-code/session.mjs +15 -7
  22. package/src/runtimes/codex/index.mjs +29 -41
  23. package/src/runtimes/codex/session.mjs +9 -5
  24. package/src/runtimes/openclaw/index.mjs +59 -31
  25. package/src/runtimes/openclaw/target.mjs +0 -30
  26. package/src/runtimes/opencode/index.mjs +34 -56
  27. package/src/runtimes/opencode/session.mjs +11 -2
  28. package/src/runtimes/pi/index.mjs +31 -51
  29. package/src/runtimes/pi/session.mjs +8 -2
  30. package/ticlawk.mjs +37 -10
  31. package/assets/ticlawk-concept.svg +0 -137
  32. package/src/adapters/telegram/index.mjs +0 -359
  33. package/src/adapters/ticlawk/cards.mjs +0 -149
  34. package/src/core/media/outbound.mjs +0 -163
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Per-agent home directory: ~/.ticlawk/agents/<agent_id>/
3
+ *
4
+ * The agent's authoritative workspace. The daemon spawns every runtime
5
+ * with this as cwd; the agent's MEMORY.md, notes/, and any artifacts
6
+ * it produces live here. No project binding — one MEMORY.md per agent,
7
+ * lives in cwd, agent reads it via `cat MEMORY.md`.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { AF_HOME } from './config.mjs';
13
+
14
+ export const AF_AGENTS_DIR = join(AF_HOME, 'agents');
15
+
16
+ export function getAgentHome(agentId) {
17
+ if (!agentId) throw new Error('getAgentHome: agentId is required');
18
+ return join(AF_AGENTS_DIR, String(agentId));
19
+ }
20
+
21
+ export function getAgentMemoryPath(agentId) {
22
+ return join(getAgentHome(agentId), 'MEMORY.md');
23
+ }
24
+
25
+ /**
26
+ * Make sure the agent home dir exists + has a starter MEMORY.md. Idempotent;
27
+ * safe to call on every spawn. The daemon does this before driver.spawn()
28
+ * so cwd is always a real, writable directory.
29
+ *
30
+ * Pass `displayName` only when seeding for the first time — existing
31
+ * MEMORY.md is never overwritten (the agent owns it).
32
+ */
33
+ export function ensureAgentHome(agentId, { displayName } = {}) {
34
+ const home = getAgentHome(agentId);
35
+ mkdirSync(home, { recursive: true });
36
+ const memoryPath = getAgentMemoryPath(agentId);
37
+ if (!existsSync(memoryPath)) {
38
+ writeFileSync(memoryPath, buildInitialMemoryMd({ displayName, home }), 'utf8');
39
+ }
40
+ return home;
41
+ }
42
+
43
+ function buildInitialMemoryMd({ displayName, home }) {
44
+ const lines = [
45
+ `# ${displayName || 'Agent'}`,
46
+ '',
47
+ '## Role',
48
+ '<your role definition, evolved over time>',
49
+ '',
50
+ '## Workspace',
51
+ home,
52
+ '',
53
+ ];
54
+ lines.push(
55
+ '## Key Knowledge',
56
+ '- (none yet — populate as you learn the user, project, and domain)',
57
+ '',
58
+ '## Active Context',
59
+ '- (none)',
60
+ '',
61
+ '## How to update this file',
62
+ 'MEMORY.md is your entry point. Read it at the top of every turn. Add a',
63
+ '`notes/<topic>.md` for each long-lived knowledge area and link it here.',
64
+ '',
65
+ );
66
+ return lines.join('\n');
67
+ }
68
+
69
+ /**
70
+ * Best-effort read of the "## Workspace" line from a MEMORY.md. Useful
71
+ * if some code wants to know where the agent currently believes it
72
+ * works (e.g. for UI display). The daemon does NOT use this for spawn
73
+ * cwd — spawn cwd is always getAgentHome(id).
74
+ */
75
+ export function readWorkspaceFromMemory(agentId) {
76
+ const memoryPath = getAgentMemoryPath(agentId);
77
+ if (!existsSync(memoryPath)) return null;
78
+ try {
79
+ const text = readFileSync(memoryPath, 'utf8');
80
+ const m = text.match(/^##\s+Workspace\s*\n([^\n]+)/m);
81
+ return m ? m[1].trim() : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
@@ -17,7 +17,6 @@ export const AF_HOME = process.env.TICLAWK_HOME || join(homedir(), '.ticlawk');
17
17
  export const AF_CONFIG_PATH = join(AF_HOME, '.config');
18
18
  export const AF_LOG_PATH = join(AF_HOME, 'ticlawk.log');
19
19
  export const AF_CRASH_LOG_PATH = join(AF_HOME, 'ticlawk-crash.log');
20
- export const AF_ADAPTER_KEY = 'AF_ADAPTER';
21
20
  export const AF_STREAMING_KEY = 'AF_STREAMING';
22
21
  export const AF_STREAMING_RUNTIME_KEYS = Object.fromEntries(
23
22
  RUNTIME_DEFINITIONS
@@ -31,28 +30,14 @@ export const RUNTIME_EXECUTABLE_CONFIG_KEYS = Object.fromEntries(
31
30
  .filter((runtime) => runtime.executableConfigKey)
32
31
  .map((runtime) => [runtime.name, runtime.executableConfigKey])
33
32
  );
34
- export const SUPPORTED_ADAPTERS = ['ticlawk', 'telegram'];
35
33
  // Public CLI keys stay kebab/dotted for usability; the persisted .config file
36
34
  // stores env-style names because systemd/launchd load it directly.
37
35
  export const ADAPTER_CONFIG_KEYS = {
38
- 'telegram.bot-token': 'TELEGRAM_BOT_TOKEN',
39
36
  'ticlawk.connector-api-key': TICLAWK_CONNECTOR_API_KEY,
40
37
  'ticlawk.api-url': 'TICLAWK_API_URL',
41
38
  'ticlawk.connector-ws-url': TICLAWK_CONNECTOR_WS_URL,
42
39
  };
43
40
 
44
- export function normalizeAdapterName(value) {
45
- const normalized = String(value || '').trim().toLowerCase().replace(/[-\s]+/g, '_');
46
- if (!normalized) return null;
47
- if (SUPPORTED_ADAPTERS.includes(normalized)) return normalized;
48
- return null;
49
- }
50
-
51
- export function getConfiguredAdapter(config = null) {
52
- const source = config || loadPersistentConfig();
53
- return normalizeAdapterName(source[AF_ADAPTER_KEY]) || 'ticlawk';
54
- }
55
-
56
41
  export function normalizeAdapterConfigTarget(target) {
57
42
  const normalized = String(target || '').trim().toLowerCase();
58
43
  const configKey = ADAPTER_CONFIG_KEYS[normalized];
package/src/core/http.mjs CHANGED
@@ -1,4 +1,32 @@
1
1
  import { createServer } from 'node:http';
2
+ import {
3
+ handleAttachmentUpload,
4
+ handleAttachmentView,
5
+ handleGroupCreate,
6
+ handleGroupMembers,
7
+ handleGroupMembersAdd,
8
+ handleGroupMembersRemove,
9
+ handleMessageCheck,
10
+ handleMessageReact,
11
+ handleMessageRead,
12
+ handleMessageSearch,
13
+ handleMessageSend,
14
+ handleProfileAvatarUpload,
15
+ handleProfileShow,
16
+ handleProfileUpdate,
17
+ handleReminderCancel,
18
+ handleReminderList,
19
+ handleReminderLog,
20
+ handleReminderSchedule,
21
+ handleReminderSnooze,
22
+ handleReminderUpdate,
23
+ handleServerInfo,
24
+ handleTaskClaim,
25
+ handleTaskCreate,
26
+ handleTaskList,
27
+ handleTaskUnclaim,
28
+ handleTaskUpdate,
29
+ } from './agent-cli-handlers.mjs';
2
30
 
3
31
  function writeJson(res, statusCode, body) {
4
32
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@@ -13,36 +41,197 @@ async function readBody(req) {
13
41
  return body;
14
42
  }
15
43
 
16
- export function startLocalHttpServer({ port, adapter }) {
44
+ async function readJsonBody(req) {
45
+ const raw = await readBody(req);
46
+ if (!raw) return {};
47
+ try {
48
+ return JSON.parse(raw);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function parseQuery(url) {
55
+ const out = {};
56
+ const idx = url.indexOf('?');
57
+ if (idx < 0) return out;
58
+ const qs = new URLSearchParams(url.slice(idx + 1));
59
+ for (const [k, v] of qs.entries()) out[k] = v;
60
+ return out;
61
+ }
62
+
63
+ export function startLocalHttpServer({ port, adapter, ctx }) {
64
+ // The agent-cli handlers need a tiny binding-lookup surface to
65
+ // validate TICLAWK_RUNTIME_AGENT_ID against locally bound agents.
66
+ // We accept the same ctx the adapter was constructed with.
67
+ const cliCtx = ctx || { listBindings: () => [] };
68
+
17
69
  const server = createServer(async (req, res) => {
18
70
  res.setHeader('Access-Control-Allow-Origin', '*');
19
71
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
20
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
72
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Ticlawk-Acting-Agent-Id, X-Ticlawk-Runtime-Host-Id, X-Ticlawk-Runtime-Session-Id');
21
73
  if (req.method === 'OPTIONS') {
22
74
  res.writeHead(204);
23
75
  res.end();
24
76
  return;
25
77
  }
26
78
 
27
- if (req.method === 'GET' && req.url === '/health') {
28
- try {
79
+ const method = (req.method || 'GET').toUpperCase();
80
+ const urlNoQuery = (req.url || '').split('?')[0];
81
+
82
+ try {
83
+ if (method === 'GET' && urlNoQuery === '/health') {
29
84
  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
- });
85
+ writeJson(res, 200, { ok: true, adapter: adapter.id, ...health });
86
+ return;
41
87
  }
42
- return;
43
- }
44
88
 
45
- writeJson(res, 404, { error: 'not found' });
89
+ // ── Agent CLI surface (called from runtime CLI tools) ──
90
+ if (urlNoQuery === '/agent/message/send' && method === 'POST') {
91
+ const body = await readJsonBody(req);
92
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
93
+ const r = await handleMessageSend(req, body, cliCtx);
94
+ return writeJson(res, r.status, r.body);
95
+ }
96
+ if (urlNoQuery === '/agent/message/read' && method === 'GET') {
97
+ const r = await handleMessageRead(req, parseQuery(req.url || ''), cliCtx);
98
+ return writeJson(res, r.status, r.body);
99
+ }
100
+ if (urlNoQuery === '/agent/task/create' && method === 'POST') {
101
+ const body = await readJsonBody(req);
102
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
103
+ const r = await handleTaskCreate(req, body, cliCtx);
104
+ return writeJson(res, r.status, r.body);
105
+ }
106
+ if (urlNoQuery === '/agent/task/claim' && method === 'POST') {
107
+ const body = await readJsonBody(req);
108
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
109
+ const r = await handleTaskClaim(req, body, cliCtx);
110
+ return writeJson(res, r.status, r.body);
111
+ }
112
+ if (urlNoQuery === '/agent/task/unclaim' && method === 'POST') {
113
+ const body = await readJsonBody(req);
114
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
115
+ const r = await handleTaskUnclaim(req, body, cliCtx);
116
+ return writeJson(res, r.status, r.body);
117
+ }
118
+ if (urlNoQuery === '/agent/task/update' && method === 'POST') {
119
+ const body = await readJsonBody(req);
120
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
121
+ const r = await handleTaskUpdate(req, body, cliCtx);
122
+ return writeJson(res, r.status, r.body);
123
+ }
124
+ if (urlNoQuery === '/agent/task/list' && method === 'GET') {
125
+ const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
126
+ return writeJson(res, r.status, r.body);
127
+ }
128
+ if (urlNoQuery === '/agent/message/react' && method === 'POST') {
129
+ const body = await readJsonBody(req);
130
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
131
+ const r = await handleMessageReact(req, body, cliCtx);
132
+ return writeJson(res, r.status, r.body);
133
+ }
134
+ if (urlNoQuery === '/agent/message/check' && method === 'GET') {
135
+ const r = await handleMessageCheck(req, parseQuery(req.url || ''), cliCtx);
136
+ return writeJson(res, r.status, r.body);
137
+ }
138
+ if (urlNoQuery === '/agent/message/search' && method === 'GET') {
139
+ const r = await handleMessageSearch(req, parseQuery(req.url || ''), cliCtx);
140
+ return writeJson(res, r.status, r.body);
141
+ }
142
+ if (urlNoQuery === '/agent/profile/show' && method === 'GET') {
143
+ const r = await handleProfileShow(req, parseQuery(req.url || ''), cliCtx);
144
+ return writeJson(res, r.status, r.body);
145
+ }
146
+ if (urlNoQuery === '/agent/profile/update' && method === 'POST') {
147
+ const body = await readJsonBody(req);
148
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
149
+ const r = await handleProfileUpdate(req, body, cliCtx);
150
+ return writeJson(res, r.status, r.body);
151
+ }
152
+ if (urlNoQuery === '/agent/profile/avatar' && method === 'POST') {
153
+ const body = await readJsonBody(req);
154
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
155
+ const r = await handleProfileAvatarUpload(req, body, cliCtx);
156
+ return writeJson(res, r.status, r.body);
157
+ }
158
+ if (urlNoQuery === '/agent/attachment/upload' && method === 'POST') {
159
+ const body = await readJsonBody(req);
160
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
161
+ const r = await handleAttachmentUpload(req, body, cliCtx);
162
+ return writeJson(res, r.status, r.body);
163
+ }
164
+ if (urlNoQuery === '/agent/attachment/view' && method === 'GET') {
165
+ const r = await handleAttachmentView(req, parseQuery(req.url || ''), cliCtx);
166
+ return writeJson(res, r.status, r.body);
167
+ }
168
+ if (urlNoQuery === '/agent/reminder/schedule' && method === 'POST') {
169
+ const body = await readJsonBody(req);
170
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
171
+ const r = await handleReminderSchedule(req, body, cliCtx);
172
+ return writeJson(res, r.status, r.body);
173
+ }
174
+ if (urlNoQuery === '/agent/reminder/list' && method === 'GET') {
175
+ const r = await handleReminderList(req, parseQuery(req.url || ''), cliCtx);
176
+ return writeJson(res, r.status, r.body);
177
+ }
178
+ if (urlNoQuery === '/agent/reminder/snooze' && method === 'POST') {
179
+ const body = await readJsonBody(req);
180
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
181
+ const r = await handleReminderSnooze(req, body, cliCtx);
182
+ return writeJson(res, r.status, r.body);
183
+ }
184
+ if (urlNoQuery === '/agent/reminder/update' && method === 'POST') {
185
+ const body = await readJsonBody(req);
186
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
187
+ const r = await handleReminderUpdate(req, body, cliCtx);
188
+ return writeJson(res, r.status, r.body);
189
+ }
190
+ if (urlNoQuery === '/agent/reminder/cancel' && method === 'POST') {
191
+ const body = await readJsonBody(req);
192
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
193
+ const r = await handleReminderCancel(req, body, cliCtx);
194
+ return writeJson(res, r.status, r.body);
195
+ }
196
+ if (urlNoQuery === '/agent/reminder/log' && method === 'GET') {
197
+ const r = await handleReminderLog(req, parseQuery(req.url || ''), cliCtx);
198
+ return writeJson(res, r.status, r.body);
199
+ }
200
+ if (urlNoQuery === '/agent/group/members' && method === 'GET') {
201
+ const r = await handleGroupMembers(req, parseQuery(req.url || ''), cliCtx);
202
+ return writeJson(res, r.status, r.body);
203
+ }
204
+ if (urlNoQuery === '/agent/group/create' && method === 'POST') {
205
+ const body = await readJsonBody(req);
206
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
207
+ const r = await handleGroupCreate(req, body, cliCtx);
208
+ return writeJson(res, r.status, r.body);
209
+ }
210
+ if (urlNoQuery === '/agent/group/members/add' && method === 'POST') {
211
+ const body = await readJsonBody(req);
212
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
213
+ const r = await handleGroupMembersAdd(req, body, cliCtx);
214
+ return writeJson(res, r.status, r.body);
215
+ }
216
+ if (urlNoQuery === '/agent/group/members/remove' && method === 'POST') {
217
+ const body = await readJsonBody(req);
218
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
219
+ const r = await handleGroupMembersRemove(req, body, cliCtx);
220
+ return writeJson(res, r.status, r.body);
221
+ }
222
+ if (urlNoQuery === '/agent/server/info' && method === 'GET') {
223
+ const r = await handleServerInfo(req, parseQuery(req.url || ''), cliCtx);
224
+ return writeJson(res, r.status, r.body);
225
+ }
226
+
227
+ writeJson(res, 404, { error: 'not found' });
228
+ } catch (err) {
229
+ writeJson(res, 500, {
230
+ ok: false,
231
+ adapter: adapter.id,
232
+ error: err?.message || String(err),
233
+ });
234
+ }
46
235
  });
47
236
 
48
237
  server.on('error', (err) => {
@@ -59,6 +248,10 @@ export function startLocalHttpServer({ port, adapter }) {
59
248
  console.log(`[relay] HTTP server listening on :${port}`);
60
249
  console.log(`[relay] adapter: ${adapter.id}`);
61
250
  console.log('[relay] GET /health - daemon status check');
251
+ console.log('[relay] POST /agent/message/send - chat send (CLI -> daemon -> backend)');
252
+ console.log('[relay] GET /agent/message/read');
253
+ console.log('[relay] POST /agent/task/claim | update');
254
+ console.log('[relay] GET /agent/task/list | /agent/group/members | /agent/server/info');
62
255
  });
63
256
 
64
257
  return server;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Daemon-side reminder ticker.
3
+ *
4
+ * Runs every 60s. POSTs /api/agent/reminders/fire-due via the existing
5
+ * service-role connector path. The edge function calls the
6
+ * fire_due_reminders SQL function which atomically claims due
7
+ * reminders, posts a system message into each anchor conversation, and
8
+ * inserts the explicit message_delivery row to the owner agent.
9
+ *
10
+ * Failures are logged at boundaries and do not retry — the next tick
11
+ * picks them up. This is the only writer of "reminder fired" state, so
12
+ * there is no double-fire risk even across multiple daemon restarts
13
+ * (FOR UPDATE SKIP LOCKED inside the SQL function serialises).
14
+ *
15
+ * Disabled by setting TICLAWK_DISABLE_REMINDER_TICKER=1 in the
16
+ * environment (useful if you want to drive firing from elsewhere).
17
+ */
18
+
19
+ import { fireDueReminders } from '../adapters/ticlawk/api.mjs';
20
+ import { debugError, debugLog } from './logger.mjs';
21
+
22
+ const DEFAULT_INTERVAL_MS = 60 * 1000;
23
+
24
+ export function startReminderTicker({
25
+ intervalMs = DEFAULT_INTERVAL_MS,
26
+ } = {}) {
27
+ if (String(process.env.TICLAWK_DISABLE_REMINDER_TICKER || '').trim() === '1') {
28
+ debugLog('reminder-ticker', 'disabled', { reason: 'TICLAWK_DISABLE_REMINDER_TICKER=1' });
29
+ return { stop: () => {} };
30
+ }
31
+
32
+ let stopped = false;
33
+ let timer = null;
34
+
35
+ async function tick() {
36
+ if (stopped) return;
37
+ try {
38
+ const res = await fireDueReminders();
39
+ const count = res?.fired_count || 0;
40
+ if (count > 0) {
41
+ debugLog('reminder-ticker', 'fired', {
42
+ count,
43
+ ids: (res?.fired || []).map((r) => r?.reminder_id),
44
+ });
45
+ }
46
+ } catch (err) {
47
+ debugError('reminder-ticker', 'tick.failed', {
48
+ error: err?.message || String(err),
49
+ status: err?.status || null,
50
+ });
51
+ } finally {
52
+ if (!stopped) timer = setTimeout(tick, intervalMs);
53
+ }
54
+ }
55
+
56
+ debugLog('reminder-ticker', 'start', { intervalMs });
57
+ // Start with an initial small delay so the daemon's HTTP server is
58
+ // up before the first tick.
59
+ timer = setTimeout(tick, 5000);
60
+
61
+ return {
62
+ stop() {
63
+ stopped = true;
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ timer = null;
67
+ }
68
+ },
69
+ };
70
+ }
@@ -42,7 +42,7 @@ import { normalizeServiceType } from './runtime-registry.mjs';
42
42
  * @property {(bindingId: string) => any} getBinding
43
43
  * @property {(filters?: Record<string, any>) => any[]} listBindings
44
44
  * @property {(bindingId: string) => Promise<void>} [deleteBinding]
45
- * @property {(binding: any) => Promise<any>} [cacheBinding]
45
+ * @property {(binding: any) => Promise<any>} [persistBinding]
46
46
  * @property {(binding: any) => Promise<any>} upsertBinding
47
47
  * @property {(inbound: any, runtimeName: string) => Promise<string>} buildImageMessageFromInbound
48
48
  * @property {any} logger
@@ -1,9 +1,45 @@
1
+ /**
2
+ * Build the env passed to a spawned runtime (Codex/Claude Code/...).
3
+ *
4
+ * The blanket "strip everything TICLAWK_*" rule from earlier versions was
5
+ * too aggressive: it also wiped out the agent-context env we inject for
6
+ * the agent CLI to talk back to the local daemon. We now use a precise
7
+ * denylist of secret/config keys and explicitly allow the
8
+ * TICLAWK_RUNTIME_* and TICLAWK_API_URL keys through (the latter is
9
+ * read-only from the runtime's perspective — it never holds a credential
10
+ * on its own).
11
+ */
12
+
13
+ // Keys the daemon must never leak into child runtime processes.
14
+ // These hold credentials or operator config the agent shouldn't see.
15
+ const STRIPPED_KEYS = new Set([
16
+ 'TICLAWK_CONNECTOR_API_KEY',
17
+ 'TICLAWK_CONNECTOR_WS_URL',
18
+ 'TICLAWK_SETUP_CODE',
19
+ ]);
20
+
1
21
  export function buildRuntimeEnv(extra = {}) {
2
22
  const env = { ...process.env, ...extra };
3
- for (const key of Object.keys(env)) {
4
- if (key.startsWith('TICLAWK_')) {
5
- delete env[key];
6
- }
7
- }
23
+ for (const key of STRIPPED_KEYS) delete env[key];
8
24
  return env;
9
25
  }
26
+
27
+ /**
28
+ * Build the per-agent runtime env block. The daemon includes the
29
+ * resulting fields in `buildRuntimeEnv({ ...buildAgentRuntimeEnv(...) })`
30
+ * when spawning a runtime, so the agent CLI can talk back to the local
31
+ * daemon with a validated identity.
32
+ */
33
+ export function buildAgentRuntimeEnv({
34
+ agentId,
35
+ sessionId,
36
+ hostId,
37
+ daemonUrl,
38
+ } = {}) {
39
+ const out = {};
40
+ if (agentId) out.TICLAWK_RUNTIME_AGENT_ID = String(agentId);
41
+ if (sessionId) out.TICLAWK_RUNTIME_SESSION_ID = String(sessionId);
42
+ if (hostId) out.TICLAWK_RUNTIME_HOST_ID = String(hostId);
43
+ out.TICLAWK_RUNTIME_DAEMON_URL = daemonUrl || 'http://127.0.0.1:8741';
44
+ return out;
45
+ }
@@ -1,4 +1,5 @@
1
1
  import { getStreamingMode } from './config.mjs';
2
+ import { getAgentHome } from './agent-home.mjs';
2
3
  const ERROR_MAX_CHARS = 500;
3
4
  const DEFAULT_DELTA_FLUSH_MS = 250;
4
5
  const DEFAULT_DELTA_FLUSH_CHARS = 64;
@@ -15,7 +16,7 @@ function runtimeContextLines(binding, info = {}) {
15
16
  const lines = [];
16
17
  if (meta.sessionId) lines.push(`Session: ${meta.sessionId}`);
17
18
  if (info.turnId) lines.push(`Turn: ${info.turnId}`);
18
- if (meta.cwd || meta.workdir) lines.push(`Workdir: ${meta.cwd || meta.workdir}`);
19
+ if (binding?.id) lines.push(`Workdir: ${getAgentHome(binding.id)}`);
19
20
  return lines;
20
21
  }
21
22
 
@@ -41,57 +42,43 @@ export function shouldStreamRuntime(runtimeName, runtime) {
41
42
  return Boolean(runtime?.runTurnStream) && getStreamingMode(runtimeName);
42
43
  }
43
44
 
44
- export async function sendAdapterMessage(adapter, binding, payload) {
45
- await adapter.send(binding, payload);
46
- }
47
-
48
- export async function sendResult(adapter, binding, inbound, result) {
49
- if (!result?.text && (!result?.mediaUrls || result.mediaUrls.length === 0)) return;
50
- await sendAdapterMessage(adapter, binding, {
51
- type: 'assistant',
52
- text: result.text || '',
53
- media: result.media || [],
54
- turnId: inbound.messageId || result?.turnId || null,
55
- replyToMessageId: inbound.messageId || null,
56
- });
57
- }
58
-
59
45
  export async function reportSubprocessFailure({ adapter, binding, inbound, runtimeName, info }) {
60
46
  if (!info || info.ok) return;
61
47
  const seconds = ((info.durationMs || 0) / 1000).toFixed(1);
62
- if (runtimeName === 'Codex' && isRuntimeGatewayFailure(info)) {
63
- await sendAdapterMessage(adapter, binding, {
64
- type: 'assistant',
65
- text: buildCodexGatewayFailureText({ binding, info, seconds }),
66
- media: [],
67
- turnId: inbound?.messageId || null,
68
- replyToMessageId: inbound?.messageId || null,
69
- });
70
- return;
71
- }
72
48
 
73
- let summary;
74
- if (info.kind === 'killed') {
75
- summary = `⚠️ ${runtimeName} was killed by signal ${info.signal} after ${seconds}s.`;
76
- } else if (info.kind === 'spawn-failed') {
77
- summary = `⚠️ ${runtimeName} failed to spawn: ${info.error || 'unknown error'}.`;
78
- } else if (info.kind === 'gateway-timeout') {
79
- summary = `⚠️ ${runtimeName} did not respond after ${seconds}s (timeout).`;
80
- } else if (info.kind === 'gateway-error') {
81
- summary = `⚠️ ${runtimeName} returned an error after ${seconds}s.`;
82
- } else if (typeof info.code === 'number') {
83
- summary = `⚠️ ${runtimeName} exited with code ${info.code} after ${seconds}s.`;
49
+ let text;
50
+ if (runtimeName === 'Codex' && isRuntimeGatewayFailure(info)) {
51
+ text = buildCodexGatewayFailureText({ binding, info, seconds });
84
52
  } else {
85
- summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
53
+ let summary;
54
+ if (info.kind === 'killed') {
55
+ summary = `⚠️ ${runtimeName} was killed by signal ${info.signal} after ${seconds}s.`;
56
+ } else if (info.kind === 'spawn-failed') {
57
+ summary = `⚠️ ${runtimeName} failed to spawn: ${info.error || 'unknown error'}.`;
58
+ } else if (info.kind === 'gateway-timeout') {
59
+ summary = `⚠️ ${runtimeName} did not respond after ${seconds}s (timeout).`;
60
+ } else if (info.kind === 'gateway-error') {
61
+ summary = `⚠️ ${runtimeName} returned an error after ${seconds}s.`;
62
+ } else if (typeof info.code === 'number') {
63
+ summary = `⚠️ ${runtimeName} exited with code ${info.code} after ${seconds}s.`;
64
+ } else {
65
+ summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
66
+ }
67
+ const detail = truncateError(info.errorMessage);
68
+ text = detail ? `${summary}\n\n${detail}` : `${summary}\n\nCheck ~/.ticlawk/ticlawk.log on the host for details.`;
86
69
  }
87
- const detail = truncateError(info.errorMessage);
88
- const text = detail ? `${summary}\n\n${detail}` : `${summary}\n\nCheck ~/.ticlawk/ticlawk.log on the host for details.`;
89
- await sendAdapterMessage(adapter, binding, {
90
- type: 'assistant',
70
+
71
+ // Runtime never got to invoke `ticlawk message send`, so the daemon
72
+ // speaks for the agent. visibility='admin' so the message reaches
73
+ // owner/admin members only — the enqueue_message_deliveries trigger
74
+ // skips fan-out to member-role agents, breaking what would otherwise
75
+ // be a failure→fan-out→failure cascade when several agents share a
76
+ // broken runtime.
77
+ await adapter.postAgentReply(binding, {
78
+ conversationId: inbound?.conversationId || null,
91
79
  text,
92
- media: [],
93
- turnId: inbound?.messageId || null,
94
80
  replyToMessageId: inbound?.messageId || null,
81
+ visibility: 'admin',
95
82
  });
96
83
  }
97
84
 
@@ -1,5 +1,4 @@
1
1
  import { Bus } from './bus.mjs';
2
- import { getConfiguredAdapter } from './config.mjs';
3
2
  import { createAdapter } from './adapter-registry.mjs';
4
3
  import { getBinding, listBindings, upsertBinding, deleteBinding, findBindingByTarget } from './bindings/store.mjs';
5
4
  import { buildRuntimeContext, normalizeServiceType } from './runtime-registry.mjs';
@@ -31,7 +30,7 @@ function createUpsertBindingWithSync(runtimes, adapter) {
31
30
  };
32
31
  }
33
32
 
34
- function createCacheBinding(runtimes, getAdapter) {
33
+ function createPersistBinding(runtimes, getAdapter) {
35
34
  return async (binding) => {
36
35
  const nextBinding = await upsertBinding(binding);
37
36
  const runtime = nextBinding?.runtime ? runtimes[nextBinding.runtime] : null;
@@ -43,11 +42,13 @@ function createCacheBinding(runtimes, getAdapter) {
43
42
  };
44
43
  }
45
44
 
46
- export async function createTiclawkController(adapterId = getConfiguredAdapter()) {
45
+ const ADAPTER_ID = 'ticlawk';
46
+
47
+ export async function createTiclawkController(adapterId = ADAPTER_ID) {
47
48
  const { runtimes } = await buildRuntimeContext();
48
49
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
49
50
  let adapter;
50
- const cacheBinding = createCacheBinding(runtimes, () => adapter);
51
+ const persistBinding = createPersistBinding(runtimes, () => adapter);
51
52
  let syncBinding = async (binding) => {
52
53
  if (!adapter) {
53
54
  throw new Error('adapter not initialized');
@@ -63,7 +64,7 @@ export async function createTiclawkController(adapterId = getConfiguredAdapter()
63
64
  deleteBinding,
64
65
  findBindingByTarget,
65
66
  resolveRuntimeBinding,
66
- cacheBinding: (binding) => cacheBinding(binding),
67
+ persistBinding: (binding) => persistBinding(binding),
67
68
  upsertBinding: (binding) => syncBinding(binding),
68
69
  buildImageMessageFromInbound,
69
70
  logger,
@@ -74,7 +75,7 @@ export async function createTiclawkController(adapterId = getConfiguredAdapter()
74
75
  }
75
76
 
76
77
  export async function runTiclawkConnect(payload) {
77
- const adapterId = payload?.adapter || getConfiguredAdapter();
78
+ const adapterId = payload?.adapter || ADAPTER_ID;
78
79
  const { adapter } = await createTiclawkController(adapterId);
79
80
  if (typeof adapter.connect !== 'function') {
80
81
  return {