ticlawk 0.1.16-dev.1 → 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.
@@ -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 = shortMsgId(msg.thread_root_message_id || msg.message_id);
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 = shortMsgId(msg.id || msg.message_id);
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
- return `[target=${target} msg=${msgId} seq=${seq} time=${time} type=${type}] @${sender}:`;
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 || msg.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 header = buildEnvelopeHeader({ ...msg, id: messageId });
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
- text: header ? `${header} ${rawText}` : rawText,
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
- 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();
144
234
  }
145
235
 
146
- function buildBindingFromClaimedMessage(msg) {
147
- // Delivery rows expose the recipient agent under recipient_agent_id;
148
- // older code paths may pass plain agent_id. Either works.
149
- const agentId = String(
150
- msg.recipient_agent_id
151
- || msg.agent_id
152
- || msg.agentId
153
- || ''
154
- ).trim();
155
- const runtime = msg.agent_service_type || msg.service_type;
156
- const rawMeta = msg.agent_meta || msg.meta;
157
- const sourceMeta = (rawMeta && typeof rawMeta === 'object') ? { ...rawMeta } : {};
158
- const fallbackWorkdir = runtime === 'openclaw' && sourceMeta.agentId
159
- ? resolveOpenClawWorkspace(sourceMeta.agentId)
160
- : '';
161
- const workdir = getWorkdir(sourceMeta) || fallbackWorkdir;
162
- if (workdir) {
163
- sourceMeta.workdir = workdir;
164
- sourceMeta.projectDir = sourceMeta.projectDir || workdir;
165
- 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];
166
273
  }
167
- const rawRuntimeVersion = msg.agent_runtime_version ?? msg.runtime_version;
168
- const runtimeVersion = Number.isInteger(rawRuntimeVersion) ? Number(rawRuntimeVersion) : 0;
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
- agentId,
176
- runtime_host_id: getRuntimeHostIdFromPayload(msg) || undefined,
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
- ...sourceMeta,
183
- runtimeVersion,
316
+ ...projectRuntimeMeta(meta),
317
+ runtimeVersion: normalizeRuntimeVersion(runtimeVersion),
184
318
  },
185
- displayName: msg.agent_display_name || msg.agent_name || agentId,
186
- status: msg.agent_status || msg.status || 'connected',
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.cacheBinding(buildBindingFromClaimedMessage(msg));
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.cacheBinding(buildBindingFromChannelSnapshot(agent));
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) || '__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
+ }
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
- function requestDrain(reason) {
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
- // Stale-delivery recovery is handled server-side via lease expiry
1020
- // (message_deliveries.claimed_at + lease_seconds). The connector no
1021
- // longer issues an explicit recover call at startup.
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
- async send(binding, outbound) {
1192
- const localMediaPaths = (outbound.media || [])
1193
- .filter((item) => item.kind === 'local_path')
1194
- .map((item) => item.value);
1195
- await processAndSaveResult({
1196
- text: outbound.text || '',
1197
- mediaUrls: localMediaPaths,
1198
- }, {
1199
- agentId: binding.id,
1200
- hostId,
1201
- agent: binding.runtime,
1202
- sessionId: getBindingSessionId(binding),
1203
- runtimeVersion: getRuntimeVersion(binding),
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