ticlawk 0.1.16-dev.1 → 0.1.16-dev.11
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 +13 -0
- package/bin/ticlawk.mjs +116 -0
- package/package.json +1 -1
- package/src/adapters/ticlawk/api.mjs +226 -28
- package/src/adapters/ticlawk/index.mjs +258 -113
- package/src/cli/agent-commands.mjs +594 -8
- package/src/core/agent-cli-handlers.mjs +443 -3
- package/src/core/agent-home.mjs +85 -0
- package/src/core/argv.mjs +11 -1
- package/src/core/http.mjs +121 -0
- package/src/core/reminder-ticker.mjs +70 -0
- package/src/core/runtime-contract.mjs +1 -1
- package/src/core/runtime-support.mjs +31 -59
- package/src/core/ticlawk-control.mjs +3 -3
- package/src/migrate/write-initial-memory.mjs +101 -0
- package/src/runtimes/_shared/standing-prompt.mjs +296 -77
- package/src/runtimes/claude-code/index.mjs +28 -131
- package/src/runtimes/codex/index.mjs +15 -39
- package/src/runtimes/openclaw/index.mjs +39 -30
- package/src/runtimes/openclaw/target.mjs +0 -30
- package/src/runtimes/opencode/index.mjs +19 -54
- package/src/runtimes/pi/index.mjs +16 -49
- package/ticlawk.mjs +31 -6
- package/src/adapters/ticlawk/cards.mjs +0 -149
- package/src/core/media/outbound.mjs +0 -163
|
@@ -9,9 +9,7 @@ import { getActiveProfile, ensureLegacyProfile, readProfileMeta, saveAndActivate
|
|
|
9
9
|
import { isTerminalRuntimeFailure } from '../../core/runtime-support.mjs';
|
|
10
10
|
import { clearUpdateRequiredState, readUpdateState, setUpdateRequiredState } from '../../core/update-state.mjs';
|
|
11
11
|
import { isManagedInstall, startDetachedSelfUpdate } from '../../core/update.mjs';
|
|
12
|
-
import { resolveOpenClawWorkspace } from '../../runtimes/openclaw/target.mjs';
|
|
13
12
|
import * as api from './api.mjs';
|
|
14
|
-
import { processAndSaveResult } from './cards.mjs';
|
|
15
13
|
import { persistApiCredential } from './credentials.mjs';
|
|
16
14
|
import { TiclawkWakeClient } from './wake-client.mjs';
|
|
17
15
|
|
|
@@ -28,6 +26,32 @@ function connectError(statusCode, error) {
|
|
|
28
26
|
return { statusCode, body: { ok: false, error } };
|
|
29
27
|
}
|
|
30
28
|
|
|
29
|
+
// Coalesce: returns a wrapped fn that runs at most one invocation at a
|
|
30
|
+
// time. If it's called while an invocation is in flight, the most recent
|
|
31
|
+
// args are stashed and the wrapped fn re-runs exactly once after the
|
|
32
|
+
// current run completes (regardless of how many times it was called
|
|
33
|
+
// during the run). The wrapped fn always returns the in-flight Promise.
|
|
34
|
+
function coalesce(fn) {
|
|
35
|
+
let running = null;
|
|
36
|
+
let pendingArgs = null;
|
|
37
|
+
return function call(...args) {
|
|
38
|
+
if (running) {
|
|
39
|
+
pendingArgs = args;
|
|
40
|
+
return running;
|
|
41
|
+
}
|
|
42
|
+
running = (async () => {
|
|
43
|
+
let currentArgs = args;
|
|
44
|
+
while (true) {
|
|
45
|
+
pendingArgs = null;
|
|
46
|
+
await fn(...currentArgs);
|
|
47
|
+
if (pendingArgs === null) return;
|
|
48
|
+
currentArgs = pendingArgs;
|
|
49
|
+
}
|
|
50
|
+
})().finally(() => { running = null; });
|
|
51
|
+
return running;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
function normalizeInboundMediaAssets(msg) {
|
|
32
56
|
if (!Array.isArray(msg?.media_assets)) return [];
|
|
33
57
|
return msg.media_assets
|
|
@@ -45,10 +69,6 @@ function normalizeInboundMediaAssets(msg) {
|
|
|
45
69
|
.filter(Boolean);
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
function shortMsgId(value) {
|
|
49
|
-
return value ? String(value).slice(0, 8) : '';
|
|
50
|
-
}
|
|
51
|
-
|
|
52
72
|
function buildEnvelopeTarget(msg) {
|
|
53
73
|
const convType = msg.conversation_type || 'dm';
|
|
54
74
|
const conversationId = msg.conversation_id || '';
|
|
@@ -57,13 +77,8 @@ function buildEnvelopeTarget(msg) {
|
|
|
57
77
|
return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
|
|
58
78
|
}
|
|
59
79
|
if (convType === 'thread') {
|
|
60
|
-
// For thread deliveries the message itself is in-thread; reply target
|
|
61
|
-
// is the parent group's id + the thread root's short id. The RPC
|
|
62
|
-
// returns conversation_type='thread' only when the message lives in
|
|
63
|
-
// a thread conversation directly, which we represent as
|
|
64
|
-
// `<group>:<thread-root>`.
|
|
65
80
|
const groupName = msg.conversation_name || conversationId;
|
|
66
|
-
const threadRoot =
|
|
81
|
+
const threadRoot = msg.thread_root_message_id || msg.message_id || '';
|
|
67
82
|
return `#${groupName}:${threadRoot}`;
|
|
68
83
|
}
|
|
69
84
|
// group
|
|
@@ -71,26 +86,104 @@ function buildEnvelopeTarget(msg) {
|
|
|
71
86
|
return `#${groupName}`;
|
|
72
87
|
}
|
|
73
88
|
|
|
89
|
+
function buildTaskSuffix(msg) {
|
|
90
|
+
if (msg.task_number == null) return '';
|
|
91
|
+
const status = msg.task_status || 'todo';
|
|
92
|
+
const parts = [`task #${msg.task_number} status=${status}`];
|
|
93
|
+
if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
|
|
94
|
+
const t = msg.task_assignee_type || 'agent';
|
|
95
|
+
const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
|
|
96
|
+
parts.push(`assignee=${t}:${id}`);
|
|
97
|
+
}
|
|
98
|
+
if (msg.task_title) {
|
|
99
|
+
parts.push(`title=${JSON.stringify(msg.task_title)}`);
|
|
100
|
+
}
|
|
101
|
+
return ` [${parts.join(' ')}]`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildReactionsSuffix(msg) {
|
|
105
|
+
const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
|
|
106
|
+
if (entries.length === 0) return '';
|
|
107
|
+
return ` [reactions: ${entries.join('; ')}]`;
|
|
108
|
+
}
|
|
109
|
+
|
|
74
110
|
function buildEnvelopeHeader(msg) {
|
|
75
111
|
const target = buildEnvelopeTarget(msg);
|
|
76
|
-
const msgId =
|
|
112
|
+
const msgId = msg.id || msg.message_id || '';
|
|
77
113
|
const seq = msg.seq != null ? msg.seq : '';
|
|
78
114
|
const time = msg.created_at || new Date().toISOString();
|
|
79
115
|
const type = msg.sender_type || 'human';
|
|
80
116
|
const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
|
|
81
|
-
|
|
117
|
+
// `reason` tells the agent how this delivery was routed: 'mention'
|
|
118
|
+
// / 'assignment' = you were directly addressed → respond by default;
|
|
119
|
+
// 'ambient' = you are in the room and saw it → respond only if
|
|
120
|
+
// clearly the right responder; 'dm' / 'thread_follow' / 'manual' =
|
|
121
|
+
// the legacy direct paths. The agent's behaviour split is in the
|
|
122
|
+
// standing prompt; we just surface the field.
|
|
123
|
+
const reason = msg.reason ? ` reason=${msg.reason}` : '';
|
|
124
|
+
return `[target=${target} msg=${msgId} seq=${seq} time=${time} type=${type}${reason}] @${sender}:`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildGroupContextBlock(msg) {
|
|
128
|
+
// Only useful in groups — DMs don't need group-purpose context.
|
|
129
|
+
if ((msg.conversation_type || 'dm') !== 'group') return '';
|
|
130
|
+
const lines = [];
|
|
131
|
+
const name = msg.conversation_name || msg.conversation_display_name || '';
|
|
132
|
+
if (name) lines.push(`name: ${name}`);
|
|
133
|
+
const description = (msg.conversation_description || '').trim();
|
|
134
|
+
if (description) lines.push(`purpose: ${description}`);
|
|
135
|
+
if (lines.length === 0) return '';
|
|
136
|
+
return [
|
|
137
|
+
'Group context:',
|
|
138
|
+
...lines.map((l) => ` ${l}`),
|
|
139
|
+
'Use `ticlawk group members --target <target>` if you need to see who else is here.',
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Wrap each per-turn message with an explicit reply instruction so the
|
|
144
|
+
// runtime LLM never has to remember the standing prompt to figure out
|
|
145
|
+
// HOW to reply. Codex in particular treats the developerInstructions as
|
|
146
|
+
// background and ignores the chat-send pattern without this per-turn
|
|
147
|
+
// nudge.
|
|
148
|
+
function buildWakePromptText({ envelopeHeader, target, rawText, groupContext }) {
|
|
149
|
+
const body = `${envelopeHeader} ${rawText || ''}`.trim();
|
|
150
|
+
const lines = ['New message received:', '', body];
|
|
151
|
+
if (groupContext) {
|
|
152
|
+
lines.push('', groupContext);
|
|
153
|
+
}
|
|
154
|
+
lines.push(
|
|
155
|
+
'',
|
|
156
|
+
`Respond as appropriate — reply using \`ticlawk message send --target "${target}"\` (body via stdin / heredoc), or take action as needed. Complete ALL your work before stopping.`,
|
|
157
|
+
'Reply in the channel or create/reply in a thread as appropriate; use each message\'s `target` and `msg` fields to choose the exact target.',
|
|
158
|
+
'',
|
|
159
|
+
'IMPORTANT: If the message requires multi-step work (research, code changes, testing), complete ALL steps before stopping. Sending a progress update does NOT mean your task is done — only stop when you have NO more work to do. The daemon will wake you again automatically when new messages arrive.',
|
|
160
|
+
);
|
|
161
|
+
return lines.join('\n');
|
|
82
162
|
}
|
|
83
163
|
|
|
84
164
|
export function normalizeInboundMessage(msg) {
|
|
85
165
|
// Claimed delivery rows carry the recipient agent id; legacy messages
|
|
86
166
|
// (history sync, manual inserts) may still use plain agent_id. Prefer
|
|
87
167
|
// the new field but fall back so the same normalizer works for both.
|
|
88
|
-
const recipientAgentId = msg.recipient_agent_id ||
|
|
168
|
+
const recipientAgentId = msg.recipient_agent_id || '';
|
|
89
169
|
const messageId = msg.id || msg.message_id || null;
|
|
90
170
|
const deliveryId = msg.delivery_id || null;
|
|
91
171
|
const media = normalizeInboundMediaAssets(msg);
|
|
92
172
|
const rawText = msg.text || '';
|
|
93
|
-
const
|
|
173
|
+
const enriched = { ...msg, id: messageId };
|
|
174
|
+
const baseHeader = buildEnvelopeHeader(enriched);
|
|
175
|
+
// Task + reactions suffixes are appended INSIDE the envelope so an
|
|
176
|
+
// agent can see at a glance whether a message is already claimed and
|
|
177
|
+
// who has already acknowledged it: `[task #N status=… assignee=…]` +
|
|
178
|
+
// `[reactions: …]`.
|
|
179
|
+
const taskSuffix = buildTaskSuffix(enriched);
|
|
180
|
+
const reactionsSuffix = buildReactionsSuffix(enriched);
|
|
181
|
+
const header = baseHeader + taskSuffix + reactionsSuffix;
|
|
182
|
+
const target = buildEnvelopeTarget(enriched);
|
|
183
|
+
const groupContext = buildGroupContextBlock(enriched);
|
|
184
|
+
const text = header
|
|
185
|
+
? buildWakePromptText({ envelopeHeader: header, target, rawText, groupContext })
|
|
186
|
+
: rawText;
|
|
94
187
|
return {
|
|
95
188
|
bindingId: recipientAgentId,
|
|
96
189
|
messageId,
|
|
@@ -99,7 +192,8 @@ export function normalizeInboundMessage(msg) {
|
|
|
99
192
|
seq: msg.seq != null ? Number(msg.seq) : null,
|
|
100
193
|
senderType: msg.sender_type || 'human',
|
|
101
194
|
envelopeHeader: header,
|
|
102
|
-
|
|
195
|
+
envelopeTarget: target,
|
|
196
|
+
text,
|
|
103
197
|
rawText,
|
|
104
198
|
action: msg.action || (media.length > 0 ? 'image' : 'task'),
|
|
105
199
|
media,
|
|
@@ -110,14 +204,6 @@ export function normalizeInboundMessage(msg) {
|
|
|
110
204
|
};
|
|
111
205
|
}
|
|
112
206
|
|
|
113
|
-
function getBindingSessionId(binding) {
|
|
114
|
-
return binding?.runtimeMeta?.sessionId || binding?.runtimeMeta?.sessionKey || binding?.runtimeMeta?.agentId || binding?.id || null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function getWorkdir(meta = {}) {
|
|
118
|
-
return String(meta.workdir || meta.projectDir || meta.cwd || '').trim();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
207
|
function getRuntimeVersion(bindingOrMeta) {
|
|
122
208
|
const value = bindingOrMeta?.runtimeMeta?.runtimeVersion ?? bindingOrMeta?.runtimeVersion ?? bindingOrMeta?.agent_runtime_version ?? null;
|
|
123
209
|
return Number.isInteger(value) ? Number(value) : null;
|
|
@@ -140,67 +226,101 @@ function getRuntimeHostLabelFromPayload(payload) {
|
|
|
140
226
|
}
|
|
141
227
|
|
|
142
228
|
function getAgentIdFromPayload(payload) {
|
|
143
|
-
|
|
229
|
+
// claim_pending_deliveries is the canonical source and returns
|
|
230
|
+
// `recipient_agent_id`. The historical fallbacks (`agent_id`,
|
|
231
|
+
// `agentId`) were left in by an earlier partial cleanup but they
|
|
232
|
+
// never fire against the current schema — dead branches.
|
|
233
|
+
return String(payload?.recipient_agent_id || '').trim();
|
|
144
234
|
}
|
|
145
235
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
236
|
+
// Whitelist of meta keys runtimes actually consume. Anything else in
|
|
237
|
+
// the source row's meta blob is dropped on the floor so stale fields
|
|
238
|
+
// (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
|
|
239
|
+
// back into runtimeMeta and confuse downstream code.
|
|
240
|
+
const RUNTIME_META_KEYS = [
|
|
241
|
+
'sessionId',
|
|
242
|
+
'path',
|
|
243
|
+
'runtimePath',
|
|
244
|
+
'rotatePending',
|
|
245
|
+
'lastRotatedAt',
|
|
246
|
+
// claude_code
|
|
247
|
+
'claudePath',
|
|
248
|
+
'claudeVersion',
|
|
249
|
+
'project',
|
|
250
|
+
// codex
|
|
251
|
+
'codexPath',
|
|
252
|
+
'codexVersion',
|
|
253
|
+
// opencode
|
|
254
|
+
'opencodePath',
|
|
255
|
+
'opencodeVersion',
|
|
256
|
+
// pi
|
|
257
|
+
'piPath',
|
|
258
|
+
'piVersion',
|
|
259
|
+
// openclaw (gateway-based)
|
|
260
|
+
'agentId',
|
|
261
|
+
'sessionKey',
|
|
262
|
+
'gatewayHost',
|
|
263
|
+
'gatewayPort',
|
|
264
|
+
'lastGatewayFailureAt',
|
|
265
|
+
'lastGatewayFailureReason',
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
function projectRuntimeMeta(source) {
|
|
269
|
+
const out = {};
|
|
270
|
+
if (!source || typeof source !== 'object') return out;
|
|
271
|
+
for (const key of RUNTIME_META_KEYS) {
|
|
272
|
+
if (source[key] !== undefined) out[key] = source[key];
|
|
166
273
|
}
|
|
167
|
-
|
|
168
|
-
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
169
276
|
|
|
277
|
+
function normalizeRuntimeVersion(value) {
|
|
278
|
+
return Number.isInteger(value) ? Number(value) : 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Build a binding from any source row that carries agent metadata.
|
|
282
|
+
// Two source shapes feed in:
|
|
283
|
+
// /api/agents row — id, service_type, meta, …
|
|
284
|
+
// claim_pending_deliveries — recipient_agent_id, agent_service_type,
|
|
285
|
+
// agent_meta, … (prefixed because the
|
|
286
|
+
// delivery row also carries message fields)
|
|
287
|
+
// We try the prefixed names first, then fall through to the bare names,
|
|
288
|
+
// so one builder handles both without the caller having to remember
|
|
289
|
+
// which shape it has.
|
|
290
|
+
function buildBindingFromSource(source) {
|
|
291
|
+
const agentId = String(
|
|
292
|
+
source?.recipient_agent_id
|
|
293
|
+
|| source?.id
|
|
294
|
+
|| source?.agent_id
|
|
295
|
+
|| ''
|
|
296
|
+
).trim();
|
|
297
|
+
const runtime = source?.agent_service_type || source?.service_type;
|
|
298
|
+
const meta = source?.agent_meta ?? source?.meta;
|
|
299
|
+
const runtimeVersion = source?.agent_runtime_version ?? source?.runtime_version;
|
|
300
|
+
const displayName = source?.agent_display_name
|
|
301
|
+
|| source?.agent_name
|
|
302
|
+
|| source?.display_name
|
|
303
|
+
|| source?.name
|
|
304
|
+
|| agentId;
|
|
305
|
+
const status = source?.agent_status || source?.status || 'connected';
|
|
306
|
+
const hostId = getRuntimeHostIdFromPayload(source) || undefined;
|
|
170
307
|
return {
|
|
171
308
|
id: agentId,
|
|
172
309
|
adapter: 'ticlawk',
|
|
173
310
|
targetKey: agentId,
|
|
174
|
-
targetMeta: {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
},
|
|
178
|
-
runtime_host_id: getRuntimeHostIdFromPayload(msg) || undefined,
|
|
179
|
-
runtime_host_label: getRuntimeHostLabelFromPayload(msg) || undefined,
|
|
311
|
+
targetMeta: { agentId, runtime_host_id: hostId },
|
|
312
|
+
runtime_host_id: hostId,
|
|
313
|
+
runtime_host_label: getRuntimeHostLabelFromPayload(source) || undefined,
|
|
180
314
|
runtime,
|
|
181
315
|
runtimeMeta: {
|
|
182
|
-
...
|
|
183
|
-
runtimeVersion,
|
|
316
|
+
...projectRuntimeMeta(meta),
|
|
317
|
+
runtimeVersion: normalizeRuntimeVersion(runtimeVersion),
|
|
184
318
|
},
|
|
185
|
-
displayName
|
|
186
|
-
status
|
|
319
|
+
displayName,
|
|
320
|
+
status,
|
|
187
321
|
};
|
|
188
322
|
}
|
|
189
323
|
|
|
190
|
-
function buildBindingFromChannelSnapshot(agent) {
|
|
191
|
-
return buildBindingFromClaimedMessage({
|
|
192
|
-
agent_id: agent.id || agent.agent_id,
|
|
193
|
-
agent_name: agent.name,
|
|
194
|
-
agent_display_name: agent.display_name,
|
|
195
|
-
agent_status: agent.status,
|
|
196
|
-
agent_service_type: agent.service_type,
|
|
197
|
-
agent_meta: agent.meta,
|
|
198
|
-
agent_runtime_version: agent.runtime_version,
|
|
199
|
-
runtime_host_id: agent.runtime_host_id,
|
|
200
|
-
runtime_host_label: agent.runtime_host_label,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
324
|
function maskIdentity(identity = {}) {
|
|
205
325
|
return {
|
|
206
326
|
userId: identity.userId || identity.user_id || identity.id || null,
|
|
@@ -352,8 +472,6 @@ export function createTiclawkAdapter(ctx) {
|
|
|
352
472
|
let bindingAuditTimer = null;
|
|
353
473
|
let jobsWakeTimer = null;
|
|
354
474
|
let bindingsWakeTimer = null;
|
|
355
|
-
let drainPromise = null;
|
|
356
|
-
let drainRequested = false;
|
|
357
475
|
let lastJobsWakeAt = 0;
|
|
358
476
|
let lastBindingsWakeAt = 0;
|
|
359
477
|
let updateRequired = null;
|
|
@@ -529,7 +647,7 @@ export function createTiclawkAdapter(ctx) {
|
|
|
529
647
|
continue;
|
|
530
648
|
}
|
|
531
649
|
|
|
532
|
-
const binding = await ctx.
|
|
650
|
+
const binding = await ctx.persistBinding(buildBindingFromSource(msg));
|
|
533
651
|
if (!binding?.runtime) {
|
|
534
652
|
throw new Error('claimed message missing runtime binding');
|
|
535
653
|
}
|
|
@@ -629,7 +747,7 @@ export function createTiclawkAdapter(ctx) {
|
|
|
629
747
|
continue;
|
|
630
748
|
}
|
|
631
749
|
try {
|
|
632
|
-
await ctx.
|
|
750
|
+
await ctx.persistBinding(buildBindingFromSource(agent));
|
|
633
751
|
hydrated += 1;
|
|
634
752
|
} catch (err) {
|
|
635
753
|
debugError('ticlawk', 'binding.hydrate-failed', {
|
|
@@ -724,7 +842,18 @@ export function createTiclawkAdapter(ctx) {
|
|
|
724
842
|
|
|
725
843
|
const grouped = new Map();
|
|
726
844
|
for (const msg of data) {
|
|
727
|
-
const agentId = getAgentIdFromPayload(msg)
|
|
845
|
+
const agentId = getAgentIdFromPayload(msg);
|
|
846
|
+
if (!agentId) {
|
|
847
|
+
// Claim rows must carry the recipient agent id; a missing value
|
|
848
|
+
// is a contract violation upstream, not something we paper over
|
|
849
|
+
// with a synthetic key.
|
|
850
|
+
debugError('ticlawk', 'claim.missing-agent-id', {
|
|
851
|
+
reason,
|
|
852
|
+
deliveryId: msg?.delivery_id,
|
|
853
|
+
messageId: msg?.message_id,
|
|
854
|
+
});
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
728
857
|
const bucket = grouped.get(agentId) || [];
|
|
729
858
|
bucket.push(msg);
|
|
730
859
|
grouped.set(agentId, bucket);
|
|
@@ -768,23 +897,7 @@ export function createTiclawkAdapter(ctx) {
|
|
|
768
897
|
return { totalClaimed, iterations };
|
|
769
898
|
}
|
|
770
899
|
|
|
771
|
-
|
|
772
|
-
if (drainPromise) {
|
|
773
|
-
drainRequested = true;
|
|
774
|
-
return drainPromise;
|
|
775
|
-
}
|
|
776
|
-
drainPromise = (async () => {
|
|
777
|
-
let currentReason = reason;
|
|
778
|
-
do {
|
|
779
|
-
drainRequested = false;
|
|
780
|
-
await runDrain(currentReason);
|
|
781
|
-
currentReason = 'drain.requested-again';
|
|
782
|
-
} while (drainRequested);
|
|
783
|
-
})().finally(() => {
|
|
784
|
-
drainPromise = null;
|
|
785
|
-
});
|
|
786
|
-
return drainPromise;
|
|
787
|
-
}
|
|
900
|
+
const requestDrain = coalesce(runDrain);
|
|
788
901
|
|
|
789
902
|
function scheduleDrain(reason) {
|
|
790
903
|
jobsWakeTimer = clearDebounce(jobsWakeTimer);
|
|
@@ -806,12 +919,46 @@ export function createTiclawkAdapter(ctx) {
|
|
|
806
919
|
bindingsWakeTimer.unref?.();
|
|
807
920
|
}
|
|
808
921
|
|
|
922
|
+
async function reportHostCapabilitiesNow() {
|
|
923
|
+
const entries = await Promise.all(Object.entries(ctx.runtimes || {})
|
|
924
|
+
.filter(([, runtime]) => typeof runtime?.health === 'function')
|
|
925
|
+
.map(async ([name, runtime]) => {
|
|
926
|
+
try {
|
|
927
|
+
return [name, await runtime.health()];
|
|
928
|
+
} catch (err) {
|
|
929
|
+
return [name, { available: false, error: err?.message || 'health probe failed' }];
|
|
930
|
+
}
|
|
931
|
+
}));
|
|
932
|
+
const runtimesHealth = Object.fromEntries(entries);
|
|
933
|
+
try {
|
|
934
|
+
await api.reportHostCapabilities({
|
|
935
|
+
hostId,
|
|
936
|
+
hostLabel,
|
|
937
|
+
runtimesHealth,
|
|
938
|
+
daemonVersion: api.getTiclawkVersion(),
|
|
939
|
+
});
|
|
940
|
+
debugLog('ticlawk', 'host.capabilities.reported', {
|
|
941
|
+
hostId,
|
|
942
|
+
runtimes: Object.entries(runtimesHealth)
|
|
943
|
+
.filter(([, v]) => v?.available)
|
|
944
|
+
.map(([k]) => k)
|
|
945
|
+
.join(','),
|
|
946
|
+
});
|
|
947
|
+
} catch (err) {
|
|
948
|
+
debugError('ticlawk', 'host.capabilities.failed', {
|
|
949
|
+
hostId,
|
|
950
|
+
error: err?.message || 'report failed',
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
809
955
|
function handleWakeEvent(event) {
|
|
810
956
|
wakeState.lastEventAt = new Date().toISOString();
|
|
811
957
|
if (event?.type === 'hello') {
|
|
812
958
|
void refreshBindings('wake.hello')
|
|
813
959
|
.then(() => requestDrain('wake.hello'))
|
|
814
960
|
.catch(() => {});
|
|
961
|
+
void reportHostCapabilitiesNow();
|
|
815
962
|
return;
|
|
816
963
|
}
|
|
817
964
|
if (event?.type === 'jobs.available') {
|
|
@@ -1016,9 +1163,10 @@ export function createTiclawkAdapter(ctx) {
|
|
|
1016
1163
|
id: 'ticlawk',
|
|
1017
1164
|
|
|
1018
1165
|
async start() {
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1166
|
+
// No stale-delivery recovery anywhere — if the daemon is killed
|
|
1167
|
+
// mid-claim, the row stays `claimed` forever. lease_expires_at
|
|
1168
|
+
// was dropped in X1; no cron has replaced it. Rare in practice;
|
|
1169
|
+
// when it happens the fix is a one-row UPDATE in supabase.
|
|
1022
1170
|
await refreshBindings('startup');
|
|
1023
1171
|
await requestDrain('startup');
|
|
1024
1172
|
connectWakeSocket();
|
|
@@ -1188,22 +1336,19 @@ export function createTiclawkAdapter(ctx) {
|
|
|
1188
1336
|
}
|
|
1189
1337
|
},
|
|
1190
1338
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
turnId: outbound.turnId || outbound.replyToMessageId || null,
|
|
1205
|
-
type: outbound.type || 'agent_message',
|
|
1206
|
-
replyToMessageId: outbound.replyToMessageId || null,
|
|
1339
|
+
// Daemon-driven impersonated reply: the runtime never reached
|
|
1340
|
+
// `ticlawk message send` (subprocess died, timeout, etc.), so the
|
|
1341
|
+
// daemon speaks for the agent. Used by reportSubprocessFailure
|
|
1342
|
+
// with visibility='admin' so the notice only reaches owners.
|
|
1343
|
+
async postAgentReply(binding, { conversationId, text, replyToMessageId, visibility }) {
|
|
1344
|
+
if (!conversationId || !text) return null;
|
|
1345
|
+
return api.sendAgentMessage({
|
|
1346
|
+
actingAgentId: binding.id,
|
|
1347
|
+
conversationId,
|
|
1348
|
+
text,
|
|
1349
|
+
replyToMessageId: replyToMessageId || null,
|
|
1350
|
+
runtimeHostId: hostId,
|
|
1351
|
+
visibility: visibility || null,
|
|
1207
1352
|
});
|
|
1208
1353
|
},
|
|
1209
1354
|
|