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.
- package/README.md +96 -212
- package/bin/ticlawk.mjs +223 -46
- package/package.json +2 -5
- package/src/adapters/ticlawk/api.mjs +308 -43
- package/src/adapters/ticlawk/credentials.mjs +1 -2
- package/src/adapters/ticlawk/index.mjs +310 -119
- package/src/cli/agent-commands.mjs +876 -0
- package/src/core/adapter-registry.mjs +12 -28
- package/src/core/agent-cli-handlers.mjs +731 -0
- package/src/core/agent-home.mjs +85 -0
- package/src/core/config.mjs +0 -15
- package/src/core/http.mjs +211 -18
- package/src/core/reminder-ticker.mjs +70 -0
- package/src/core/runtime-contract.mjs +1 -1
- package/src/core/runtime-env.mjs +41 -5
- package/src/core/runtime-support.mjs +31 -44
- package/src/core/ticlawk-control.mjs +7 -6
- package/src/migrate/write-initial-memory.mjs +101 -0
- package/src/runtimes/_shared/standing-prompt.mjs +308 -0
- package/src/runtimes/claude-code/index.mjs +49 -133
- package/src/runtimes/claude-code/session.mjs +15 -7
- package/src/runtimes/codex/index.mjs +29 -41
- package/src/runtimes/codex/session.mjs +9 -5
- package/src/runtimes/openclaw/index.mjs +59 -31
- package/src/runtimes/openclaw/target.mjs +0 -30
- package/src/runtimes/opencode/index.mjs +34 -56
- package/src/runtimes/opencode/session.mjs +11 -2
- package/src/runtimes/pi/index.mjs +31 -51
- package/src/runtimes/pi/session.mjs +8 -2
- package/ticlawk.mjs +37 -10
- package/assets/ticlawk-concept.svg +0 -137
- package/src/adapters/telegram/index.mjs +0 -359
- package/src/adapters/ticlawk/cards.mjs +0 -149
- 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
|
+
}
|
package/src/core/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>} [
|
|
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
|
package/src/core/runtime-env.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
74
|
-
if (
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
78
|
+
const adapterId = payload?.adapter || ADAPTER_ID;
|
|
78
79
|
const { adapter } = await createTiclawkController(adapterId);
|
|
79
80
|
if (typeof adapter.connect !== 'function') {
|
|
80
81
|
return {
|