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
@@ -2,16 +2,14 @@ import { parseOptionArgs } from '../../core/argv.mjs';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { createRequire } from 'node:module';
4
4
  import { basename } from 'node:path';
5
- import { AF_ADAPTER_KEY, loadPersistentConfig, persistConfig, TICLAWK_CONNECTOR_API_KEY, TICLAWK_CONNECTOR_WS_URL } from '../../core/config.mjs';
5
+ import { loadPersistentConfig, persistConfig, TICLAWK_CONNECTOR_API_KEY, TICLAWK_CONNECTOR_WS_URL } from '../../core/config.mjs';
6
6
  import { belongsToRuntimeHost, getBindingRuntimeHostId, getHostId, getHostLabel } from '../../core/host-id.mjs';
7
7
  import { debugError, debugLog } from '../../core/logger.mjs';
8
8
  import { getActiveProfile, ensureLegacyProfile, readProfileMeta, saveAndActivateProfile } from '../../core/profiles.mjs';
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,13 +69,132 @@ function normalizeInboundMediaAssets(msg) {
45
69
  .filter(Boolean);
46
70
  }
47
71
 
48
- function normalizeInboundMessage(msg) {
72
+ function buildEnvelopeTarget(msg) {
73
+ const convType = msg.conversation_type || 'dm';
74
+ const conversationId = msg.conversation_id || '';
75
+ const senderHandle = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
76
+ if (convType === 'dm') {
77
+ return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
78
+ }
79
+ if (convType === 'thread') {
80
+ const groupName = msg.conversation_name || conversationId;
81
+ const threadRoot = msg.thread_root_message_id || msg.message_id || '';
82
+ return `#${groupName}:${threadRoot}`;
83
+ }
84
+ // group
85
+ const groupName = msg.conversation_name || conversationId;
86
+ return `#${groupName}`;
87
+ }
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
+
110
+ function buildEnvelopeHeader(msg) {
111
+ const target = buildEnvelopeTarget(msg);
112
+ const msgId = msg.id || msg.message_id || '';
113
+ const seq = msg.seq != null ? msg.seq : '';
114
+ const time = msg.created_at || new Date().toISOString();
115
+ const type = msg.sender_type || 'human';
116
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
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');
162
+ }
163
+
164
+ export function normalizeInboundMessage(msg) {
165
+ // Claimed delivery rows carry the recipient agent id; legacy messages
166
+ // (history sync, manual inserts) may still use plain agent_id. Prefer
167
+ // the new field but fall back so the same normalizer works for both.
168
+ const recipientAgentId = msg.recipient_agent_id || '';
49
169
  const messageId = msg.id || msg.message_id || null;
170
+ const deliveryId = msg.delivery_id || null;
50
171
  const media = normalizeInboundMediaAssets(msg);
172
+ const rawText = msg.text || '';
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;
51
187
  return {
52
- bindingId: msg.agent_id || '',
188
+ bindingId: recipientAgentId,
53
189
  messageId,
54
- text: msg.text || '',
190
+ deliveryId,
191
+ conversationId: msg.conversation_id || null,
192
+ seq: msg.seq != null ? Number(msg.seq) : null,
193
+ senderType: msg.sender_type || 'human',
194
+ envelopeHeader: header,
195
+ envelopeTarget: target,
196
+ text,
197
+ rawText,
55
198
  action: msg.action || (media.length > 0 ? 'image' : 'task'),
56
199
  media,
57
200
  raw: {
@@ -61,14 +204,6 @@ function normalizeInboundMessage(msg) {
61
204
  };
62
205
  }
63
206
 
64
- function getBindingSessionId(binding) {
65
- return binding?.runtimeMeta?.sessionId || binding?.runtimeMeta?.sessionKey || binding?.runtimeMeta?.agentId || binding?.id || null;
66
- }
67
-
68
- function getWorkdir(meta = {}) {
69
- return String(meta.workdir || meta.projectDir || meta.cwd || '').trim();
70
- }
71
-
72
207
  function getRuntimeVersion(bindingOrMeta) {
73
208
  const value = bindingOrMeta?.runtimeMeta?.runtimeVersion ?? bindingOrMeta?.runtimeVersion ?? bindingOrMeta?.agent_runtime_version ?? null;
74
209
  return Number.isInteger(value) ? Number(value) : null;
@@ -91,60 +226,101 @@ function getRuntimeHostLabelFromPayload(payload) {
91
226
  }
92
227
 
93
228
  function getAgentIdFromPayload(payload) {
94
- return String(payload?.agent_id || payload?.agentId || '').trim();
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();
95
234
  }
96
235
 
97
- function buildBindingFromClaimedMessage(msg) {
98
- const agentId = getAgentIdFromPayload(msg);
99
- const runtime = msg.agent_service_type || msg.service_type;
100
- const rawMeta = msg.agent_meta || msg.meta;
101
- const sourceMeta = (rawMeta && typeof rawMeta === 'object') ? { ...rawMeta } : {};
102
- const fallbackWorkdir = runtime === 'openclaw' && sourceMeta.agentId
103
- ? resolveOpenClawWorkspace(sourceMeta.agentId)
104
- : '';
105
- const workdir = getWorkdir(sourceMeta) || fallbackWorkdir;
106
- if (workdir) {
107
- sourceMeta.workdir = workdir;
108
- sourceMeta.projectDir = sourceMeta.projectDir || workdir;
109
- sourceMeta.cwd = sourceMeta.cwd || workdir;
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];
110
273
  }
111
- const rawRuntimeVersion = msg.agent_runtime_version ?? msg.runtime_version;
112
- const runtimeVersion = Number.isInteger(rawRuntimeVersion) ? Number(rawRuntimeVersion) : 0;
274
+ return out;
275
+ }
276
+
277
+ function normalizeRuntimeVersion(value) {
278
+ return Number.isInteger(value) ? Number(value) : 0;
279
+ }
113
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;
114
307
  return {
115
308
  id: agentId,
116
309
  adapter: 'ticlawk',
117
310
  targetKey: agentId,
118
- targetMeta: {
119
- agentId,
120
- runtime_host_id: getRuntimeHostIdFromPayload(msg) || undefined,
121
- },
122
- runtime_host_id: getRuntimeHostIdFromPayload(msg) || undefined,
123
- 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,
124
314
  runtime,
125
315
  runtimeMeta: {
126
- ...sourceMeta,
127
- runtimeVersion,
316
+ ...projectRuntimeMeta(meta),
317
+ runtimeVersion: normalizeRuntimeVersion(runtimeVersion),
128
318
  },
129
- displayName: msg.agent_display_name || msg.agent_name || agentId,
130
- status: msg.agent_status || msg.status || 'connected',
319
+ displayName,
320
+ status,
131
321
  };
132
322
  }
133
323
 
134
- function buildBindingFromChannelSnapshot(agent) {
135
- return buildBindingFromClaimedMessage({
136
- agent_id: agent.id || agent.agent_id,
137
- agent_name: agent.name,
138
- agent_display_name: agent.display_name,
139
- agent_status: agent.status,
140
- agent_service_type: agent.service_type,
141
- agent_meta: agent.meta,
142
- agent_runtime_version: agent.runtime_version,
143
- runtime_host_id: agent.runtime_host_id,
144
- runtime_host_label: agent.runtime_host_label,
145
- });
146
- }
147
-
148
324
  function maskIdentity(identity = {}) {
149
325
  return {
150
326
  userId: identity.userId || identity.user_id || identity.id || null,
@@ -296,8 +472,6 @@ export function createTiclawkAdapter(ctx) {
296
472
  let bindingAuditTimer = null;
297
473
  let jobsWakeTimer = null;
298
474
  let bindingsWakeTimer = null;
299
- let drainPromise = null;
300
- let drainRequested = false;
301
475
  let lastJobsWakeAt = 0;
302
476
  let lastBindingsWakeAt = 0;
303
477
  let updateRequired = null;
@@ -458,12 +632,14 @@ export function createTiclawkAdapter(ctx) {
458
632
 
459
633
  async function processPendingMessagesForAgent(agentId, messages) {
460
634
  for (const msg of messages) {
635
+ const deliveryId = msg.delivery_id;
461
636
  try {
462
637
  const messageHostId = getRuntimeHostIdFromPayload(msg);
463
638
  if (messageHostId && messageHostId !== hostId) {
464
- await api.releaseMessage(msg.message_id, hostId);
639
+ await api.releaseDelivery(deliveryId, hostId, 'host-mismatch');
465
640
  debugError('ticlawk', 'message.host-mismatch', {
466
641
  agentId,
642
+ deliveryId,
467
643
  messageId: msg.message_id,
468
644
  hostId,
469
645
  runtime_host_id: messageHostId,
@@ -471,14 +647,15 @@ export function createTiclawkAdapter(ctx) {
471
647
  continue;
472
648
  }
473
649
 
474
- const binding = await ctx.cacheBinding(buildBindingFromClaimedMessage(msg));
650
+ const binding = await ctx.persistBinding(buildBindingFromSource(msg));
475
651
  if (!binding?.runtime) {
476
652
  throw new Error('claimed message missing runtime binding');
477
653
  }
478
654
  if (!belongsToRuntimeHost(binding, hostId)) {
479
- await api.releaseMessage(msg.message_id, hostId);
655
+ await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
480
656
  debugError('ticlawk', 'message.binding-host-mismatch', {
481
657
  agentId,
658
+ deliveryId,
482
659
  messageId: msg.message_id,
483
660
  hostId,
484
661
  runtime_host_id: getBindingRuntimeHostId(binding),
@@ -489,10 +666,11 @@ export function createTiclawkAdapter(ctx) {
489
666
  const completed = await ctx.bus.dispatchToAgent(binding.runtime, binding.id, normalizeInboundMessage(msg));
490
667
  if (completed !== true) {
491
668
  if (isTerminalRuntimeFailure(completed)) {
492
- await api.completeMessage(msg.message_id, hostId);
669
+ await api.completeDelivery(deliveryId, hostId);
493
670
  void requestDrain('message.terminal-completed');
494
671
  debugError('ticlawk', 'message.terminal-failed', {
495
672
  agentId,
673
+ deliveryId,
496
674
  messageId: msg.message_id,
497
675
  runtime: binding.runtime,
498
676
  runtimeVersion: getRuntimeVersion(binding),
@@ -502,10 +680,11 @@ export function createTiclawkAdapter(ctx) {
502
680
  }
503
681
  throw new Error('runtime did not complete turn');
504
682
  }
505
- await api.completeMessage(msg.message_id, hostId);
683
+ await api.completeDelivery(deliveryId, hostId);
506
684
  void requestDrain('message.completed');
507
685
  debugLog('ticlawk', 'message.completed', {
508
686
  agentId,
687
+ deliveryId,
509
688
  messageId: msg.message_id,
510
689
  runtime: binding.runtime,
511
690
  runtimeVersion: getRuntimeVersion(binding),
@@ -515,7 +694,7 @@ export function createTiclawkAdapter(ctx) {
515
694
  recordUpdateRequired(err, 'message.dispatch');
516
695
  }
517
696
  try {
518
- await api.releaseMessage(msg.message_id, hostId);
697
+ await api.releaseDelivery(deliveryId, hostId, 'dispatch-error');
519
698
  } catch (releaseErr) {
520
699
  debugError('ticlawk', 'message.release-failed', {
521
700
  agentId,
@@ -568,7 +747,7 @@ export function createTiclawkAdapter(ctx) {
568
747
  continue;
569
748
  }
570
749
  try {
571
- await ctx.cacheBinding(buildBindingFromChannelSnapshot(agent));
750
+ await ctx.persistBinding(buildBindingFromSource(agent));
572
751
  hydrated += 1;
573
752
  } catch (err) {
574
753
  debugError('ticlawk', 'binding.hydrate-failed', {
@@ -592,13 +771,14 @@ export function createTiclawkAdapter(ctx) {
592
771
 
593
772
  async function releaseBlockedRows(agentId, messages, reason) {
594
773
  for (const msg of messages) {
595
- if (!msg?.message_id) continue;
774
+ if (!msg?.delivery_id) continue;
596
775
  try {
597
- await api.releaseMessage(msg.message_id, hostId);
776
+ await api.releaseDelivery(msg.delivery_id, hostId, reason);
598
777
  } catch (err) {
599
778
  debugError('ticlawk', 'claim.blocked-release-failed', {
600
779
  reason,
601
780
  agentId,
781
+ deliveryId: msg.delivery_id,
602
782
  messageId: msg.message_id,
603
783
  error: err?.message || 'unknown error',
604
784
  });
@@ -620,7 +800,7 @@ export function createTiclawkAdapter(ctx) {
620
800
  return { failed: true, claimed: 0, launched: 0, updateRequired: true };
621
801
  }
622
802
  try {
623
- data = await api.claimPendingMessages(hostId, 5, excludedChannelIds);
803
+ data = await api.claimPendingDeliveries(hostId, 5, excludedChannelIds);
624
804
  clearUpdateRequired('claim');
625
805
  } catch (err) {
626
806
  if (api.isUpdateRequiredError(err)) {
@@ -662,7 +842,18 @@ export function createTiclawkAdapter(ctx) {
662
842
 
663
843
  const grouped = new Map();
664
844
  for (const msg of data) {
665
- const agentId = getAgentIdFromPayload(msg) || '__default__';
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
+ }
666
857
  const bucket = grouped.get(agentId) || [];
667
858
  bucket.push(msg);
668
859
  grouped.set(agentId, bucket);
@@ -706,23 +897,7 @@ export function createTiclawkAdapter(ctx) {
706
897
  return { totalClaimed, iterations };
707
898
  }
708
899
 
709
- function requestDrain(reason) {
710
- if (drainPromise) {
711
- drainRequested = true;
712
- return drainPromise;
713
- }
714
- drainPromise = (async () => {
715
- let currentReason = reason;
716
- do {
717
- drainRequested = false;
718
- await runDrain(currentReason);
719
- currentReason = 'drain.requested-again';
720
- } while (drainRequested);
721
- })().finally(() => {
722
- drainPromise = null;
723
- });
724
- return drainPromise;
725
- }
900
+ const requestDrain = coalesce(runDrain);
726
901
 
727
902
  function scheduleDrain(reason) {
728
903
  jobsWakeTimer = clearDebounce(jobsWakeTimer);
@@ -744,12 +919,46 @@ export function createTiclawkAdapter(ctx) {
744
919
  bindingsWakeTimer.unref?.();
745
920
  }
746
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
+
747
955
  function handleWakeEvent(event) {
748
956
  wakeState.lastEventAt = new Date().toISOString();
749
957
  if (event?.type === 'hello') {
750
958
  void refreshBindings('wake.hello')
751
959
  .then(() => requestDrain('wake.hello'))
752
960
  .catch(() => {});
961
+ void reportHostCapabilitiesNow();
753
962
  return;
754
963
  }
755
964
  if (event?.type === 'jobs.available') {
@@ -954,24 +1163,10 @@ export function createTiclawkAdapter(ctx) {
954
1163
  id: 'ticlawk',
955
1164
 
956
1165
  async start() {
957
- try {
958
- const recovered = await api.recoverClaimedMessages(hostId);
959
- if (recovered?.recoveredCount) {
960
- debugLog('ticlawk', 'message.recovered', {
961
- recoveredCount: recovered.recoveredCount,
962
- hostId,
963
- });
964
- }
965
- } catch (err) {
966
- if (api.isUpdateRequiredError(err)) {
967
- recordUpdateRequired(err, 'recover');
968
- }
969
- debugError('ticlawk', 'message.recover-failed', {
970
- hostId,
971
- error: err?.message || 'unknown error',
972
- });
973
- }
974
-
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.
975
1170
  await refreshBindings('startup');
976
1171
  await requestDrain('startup');
977
1172
  connectWakeSocket();
@@ -1141,22 +1336,19 @@ export function createTiclawkAdapter(ctx) {
1141
1336
  }
1142
1337
  },
1143
1338
 
1144
- async send(binding, outbound) {
1145
- const localMediaPaths = (outbound.media || [])
1146
- .filter((item) => item.kind === 'local_path')
1147
- .map((item) => item.value);
1148
- await processAndSaveResult({
1149
- text: outbound.text || '',
1150
- mediaUrls: localMediaPaths,
1151
- }, {
1152
- agentId: binding.id,
1153
- hostId,
1154
- agent: binding.runtime,
1155
- sessionId: getBindingSessionId(binding),
1156
- runtimeVersion: getRuntimeVersion(binding),
1157
- turnId: outbound.turnId || outbound.replyToMessageId || null,
1158
- type: outbound.type || 'agent_message',
1159
- 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,
1160
1352
  });
1161
1353
  },
1162
1354
 
@@ -1234,7 +1426,6 @@ export async function runTiclawkAuth(rawArgs) {
1234
1426
  };
1235
1427
  }
1236
1428
  const updates = {
1237
- [AF_ADAPTER_KEY]: 'ticlawk',
1238
1429
  TICLAWK_SETUP_CODE: code,
1239
1430
  };
1240
1431
  const apiUrl = String(args['api-url'] || '').trim();