ticlawk 0.1.16-dev.1 → 0.1.16-dev.2

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,7 +9,6 @@ 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
13
  import { processAndSaveResult } from './cards.mjs';
15
14
  import { persistApiCredential } from './credentials.mjs';
@@ -45,10 +44,6 @@ function normalizeInboundMediaAssets(msg) {
45
44
  .filter(Boolean);
46
45
  }
47
46
 
48
- function shortMsgId(value) {
49
- return value ? String(value).slice(0, 8) : '';
50
- }
51
-
52
47
  function buildEnvelopeTarget(msg) {
53
48
  const convType = msg.conversation_type || 'dm';
54
49
  const conversationId = msg.conversation_id || '';
@@ -57,13 +52,8 @@ function buildEnvelopeTarget(msg) {
57
52
  return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
58
53
  }
59
54
  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
55
  const groupName = msg.conversation_name || conversationId;
66
- const threadRoot = shortMsgId(msg.thread_root_message_id || msg.message_id);
56
+ const threadRoot = msg.thread_root_message_id || msg.message_id || '';
67
57
  return `#${groupName}:${threadRoot}`;
68
58
  }
69
59
  // group
@@ -71,26 +61,104 @@ function buildEnvelopeTarget(msg) {
71
61
  return `#${groupName}`;
72
62
  }
73
63
 
64
+ function buildTaskSuffix(msg) {
65
+ if (msg.task_number == null) return '';
66
+ const status = msg.task_status || 'todo';
67
+ const parts = [`task #${msg.task_number} status=${status}`];
68
+ if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
69
+ const t = msg.task_assignee_type || 'agent';
70
+ const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
71
+ parts.push(`assignee=${t}:${id}`);
72
+ }
73
+ if (msg.task_title) {
74
+ parts.push(`title=${JSON.stringify(msg.task_title)}`);
75
+ }
76
+ return ` [${parts.join(' ')}]`;
77
+ }
78
+
79
+ function buildReactionsSuffix(msg) {
80
+ const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
81
+ if (entries.length === 0) return '';
82
+ return ` [reactions: ${entries.join('; ')}]`;
83
+ }
84
+
74
85
  function buildEnvelopeHeader(msg) {
75
86
  const target = buildEnvelopeTarget(msg);
76
- const msgId = shortMsgId(msg.id || msg.message_id);
87
+ const msgId = msg.id || msg.message_id || '';
77
88
  const seq = msg.seq != null ? msg.seq : '';
78
89
  const time = msg.created_at || new Date().toISOString();
79
90
  const type = msg.sender_type || 'human';
80
91
  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}:`;
92
+ // `reason` tells the agent how this delivery was routed: 'mention'
93
+ // / 'assignment' = you were directly addressed → respond by default;
94
+ // 'ambient' = you are in the room and saw it → respond only if
95
+ // clearly the right responder; 'dm' / 'thread_follow' / 'manual' =
96
+ // the legacy direct paths. The agent's behaviour split is in the
97
+ // standing prompt; we just surface the field.
98
+ const reason = msg.reason ? ` reason=${msg.reason}` : '';
99
+ return `[target=${target} msg=${msgId} seq=${seq} time=${time} type=${type}${reason}] @${sender}:`;
100
+ }
101
+
102
+ function buildGroupContextBlock(msg) {
103
+ // Only useful in groups — DMs don't need group-purpose context.
104
+ if ((msg.conversation_type || 'dm') !== 'group') return '';
105
+ const lines = [];
106
+ const name = msg.conversation_name || msg.conversation_display_name || '';
107
+ if (name) lines.push(`name: ${name}`);
108
+ const description = (msg.conversation_description || '').trim();
109
+ if (description) lines.push(`purpose: ${description}`);
110
+ if (lines.length === 0) return '';
111
+ return [
112
+ 'Group context:',
113
+ ...lines.map((l) => ` ${l}`),
114
+ 'Use `ticlawk group members --target <target>` if you need to see who else is here.',
115
+ ].join('\n');
116
+ }
117
+
118
+ // Wrap each per-turn message with an explicit reply instruction so the
119
+ // runtime LLM never has to remember the standing prompt to figure out
120
+ // HOW to reply. Codex in particular treats the developerInstructions as
121
+ // background and ignores the chat-send pattern without this per-turn
122
+ // nudge (Slock does the same — see chunk-M4A5QPUN.js dynamicReplyInstruction).
123
+ function buildWakePromptText({ envelopeHeader, target, rawText, groupContext }) {
124
+ const body = `${envelopeHeader} ${rawText || ''}`.trim();
125
+ const lines = ['New message received:', '', body];
126
+ if (groupContext) {
127
+ lines.push('', groupContext);
128
+ }
129
+ lines.push(
130
+ '',
131
+ `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.`,
132
+ '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.',
133
+ '',
134
+ '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.',
135
+ );
136
+ return lines.join('\n');
82
137
  }
83
138
 
84
139
  export function normalizeInboundMessage(msg) {
85
140
  // Claimed delivery rows carry the recipient agent id; legacy messages
86
141
  // (history sync, manual inserts) may still use plain agent_id. Prefer
87
142
  // the new field but fall back so the same normalizer works for both.
88
- const recipientAgentId = msg.recipient_agent_id || msg.agent_id || '';
143
+ const recipientAgentId = msg.recipient_agent_id || '';
89
144
  const messageId = msg.id || msg.message_id || null;
90
145
  const deliveryId = msg.delivery_id || null;
91
146
  const media = normalizeInboundMediaAssets(msg);
92
147
  const rawText = msg.text || '';
93
- const header = buildEnvelopeHeader({ ...msg, id: messageId });
148
+ const enriched = { ...msg, id: messageId };
149
+ const baseHeader = buildEnvelopeHeader(enriched);
150
+ // Task + reactions suffixes are appended INSIDE the envelope so an
151
+ // agent can see at a glance whether a message is already claimed and
152
+ // who has already acknowledged it — same shape as slock's incoming
153
+ // message render (`[task #N status=… assignee=…]` + `[reactions: …]`).
154
+ const taskSuffix = buildTaskSuffix(enriched);
155
+ const reactionsSuffix = buildReactionsSuffix(enriched);
156
+ const header = baseHeader + taskSuffix + reactionsSuffix;
157
+ const target = buildEnvelopeTarget(enriched);
158
+ const groupContext = buildGroupContextBlock(enriched);
159
+ const text = header
160
+ ? buildWakePromptText({ envelopeHeader: header, target, rawText, groupContext })
161
+ : rawText;
94
162
  return {
95
163
  bindingId: recipientAgentId,
96
164
  messageId,
@@ -99,7 +167,8 @@ export function normalizeInboundMessage(msg) {
99
167
  seq: msg.seq != null ? Number(msg.seq) : null,
100
168
  senderType: msg.sender_type || 'human',
101
169
  envelopeHeader: header,
102
- text: header ? `${header} ${rawText}` : rawText,
170
+ envelopeTarget: target,
171
+ text,
103
172
  rawText,
104
173
  action: msg.action || (media.length > 0 ? 'image' : 'task'),
105
174
  media,
@@ -114,10 +183,6 @@ function getBindingSessionId(binding) {
114
183
  return binding?.runtimeMeta?.sessionId || binding?.runtimeMeta?.sessionKey || binding?.runtimeMeta?.agentId || binding?.id || null;
115
184
  }
116
185
 
117
- function getWorkdir(meta = {}) {
118
- return String(meta.workdir || meta.projectDir || meta.cwd || '').trim();
119
- }
120
-
121
186
  function getRuntimeVersion(bindingOrMeta) {
122
187
  const value = bindingOrMeta?.runtimeMeta?.runtimeVersion ?? bindingOrMeta?.runtimeVersion ?? bindingOrMeta?.agent_runtime_version ?? null;
123
188
  return Number.isInteger(value) ? Number(value) : null;
@@ -140,65 +205,104 @@ function getRuntimeHostLabelFromPayload(payload) {
140
205
  }
141
206
 
142
207
  function getAgentIdFromPayload(payload) {
143
- return String(payload?.agent_id || payload?.agentId || '').trim();
208
+ // claim_pending_deliveries is the canonical source and returns
209
+ // `recipient_agent_id`. The historical fallbacks (`agent_id`,
210
+ // `agentId`) were left in by an earlier partial cleanup but they
211
+ // never fire against the current schema — dead branches.
212
+ return String(payload?.recipient_agent_id || '').trim();
144
213
  }
145
214
 
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;
215
+ // Whitelist of meta keys runtimes actually consume. Anything else in
216
+ // the source row's meta blob is dropped on the floor so stale fields
217
+ // (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
218
+ // back into runtimeMeta and confuse downstream code.
219
+ const RUNTIME_META_KEYS = [
220
+ 'sessionId',
221
+ 'path',
222
+ 'runtimePath',
223
+ 'rotatePending',
224
+ 'lastRotatedAt',
225
+ // claude_code
226
+ 'claudePath',
227
+ 'claudeVersion',
228
+ 'project',
229
+ // codex
230
+ 'codexPath',
231
+ 'codexVersion',
232
+ // opencode
233
+ 'opencodePath',
234
+ 'opencodeVersion',
235
+ // pi
236
+ 'piPath',
237
+ 'piVersion',
238
+ // openclaw (gateway-based)
239
+ 'agentId',
240
+ 'sessionKey',
241
+ 'gatewayHost',
242
+ 'gatewayPort',
243
+ 'lastGatewayFailureAt',
244
+ 'lastGatewayFailureReason',
245
+ ];
246
+
247
+ function projectRuntimeMeta(source) {
248
+ const out = {};
249
+ if (!source || typeof source !== 'object') return out;
250
+ for (const key of RUNTIME_META_KEYS) {
251
+ if (source[key] !== undefined) out[key] = source[key];
166
252
  }
167
- const rawRuntimeVersion = msg.agent_runtime_version ?? msg.runtime_version;
168
- const runtimeVersion = Number.isInteger(rawRuntimeVersion) ? Number(rawRuntimeVersion) : 0;
253
+ return out;
254
+ }
255
+
256
+ function normalizeRuntimeVersion(value) {
257
+ return Number.isInteger(value) ? Number(value) : 0;
258
+ }
169
259
 
260
+ // Build a binding from the agent table snapshot (returned by
261
+ // /api/agents and used by the startup hydrate path). Reads agent.id,
262
+ // agent.service_type, agent.meta, etc. — the agent-row shape.
263
+ function buildBindingFromAgentRow(agent) {
264
+ const agentId = String(agent?.id || agent?.agent_id || '').trim();
265
+ const runtime = agent?.service_type;
266
+ const hostId = getRuntimeHostIdFromPayload(agent) || undefined;
170
267
  return {
171
268
  id: agentId,
172
269
  adapter: 'ticlawk',
173
270
  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,
271
+ targetMeta: { agentId, runtime_host_id: hostId },
272
+ runtime_host_id: hostId,
273
+ runtime_host_label: getRuntimeHostLabelFromPayload(agent) || undefined,
180
274
  runtime,
181
275
  runtimeMeta: {
182
- ...sourceMeta,
183
- runtimeVersion,
276
+ ...projectRuntimeMeta(agent?.meta),
277
+ runtimeVersion: normalizeRuntimeVersion(agent?.runtime_version),
184
278
  },
185
- displayName: msg.agent_display_name || msg.agent_name || agentId,
186
- status: msg.agent_status || msg.status || 'connected',
279
+ displayName: agent?.display_name || agent?.name || agentId,
280
+ status: agent?.status || 'connected',
187
281
  };
188
282
  }
189
283
 
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
- });
284
+ // Build a binding from a claimed delivery row. Reads
285
+ // recipient_agent_id, agent_service_type, agent_meta, etc. — the
286
+ // claim-payload shape returned by claim_pending_deliveries.
287
+ function buildBindingFromDeliveryRow(msg) {
288
+ const agentId = String(msg?.recipient_agent_id || '').trim();
289
+ const runtime = msg?.agent_service_type;
290
+ const hostId = getRuntimeHostIdFromPayload(msg) || undefined;
291
+ return {
292
+ id: agentId,
293
+ adapter: 'ticlawk',
294
+ targetKey: agentId,
295
+ targetMeta: { agentId, runtime_host_id: hostId },
296
+ runtime_host_id: hostId,
297
+ runtime_host_label: getRuntimeHostLabelFromPayload(msg) || undefined,
298
+ runtime,
299
+ runtimeMeta: {
300
+ ...projectRuntimeMeta(msg?.agent_meta),
301
+ runtimeVersion: normalizeRuntimeVersion(msg?.agent_runtime_version),
302
+ },
303
+ displayName: msg?.agent_display_name || msg?.agent_name || agentId,
304
+ status: msg?.agent_status || 'connected',
305
+ };
202
306
  }
203
307
 
204
308
  function maskIdentity(identity = {}) {
@@ -529,7 +633,7 @@ export function createTiclawkAdapter(ctx) {
529
633
  continue;
530
634
  }
531
635
 
532
- const binding = await ctx.cacheBinding(buildBindingFromClaimedMessage(msg));
636
+ const binding = await ctx.persistBinding(buildBindingFromDeliveryRow(msg));
533
637
  if (!binding?.runtime) {
534
638
  throw new Error('claimed message missing runtime binding');
535
639
  }
@@ -629,7 +733,7 @@ export function createTiclawkAdapter(ctx) {
629
733
  continue;
630
734
  }
631
735
  try {
632
- await ctx.cacheBinding(buildBindingFromChannelSnapshot(agent));
736
+ await ctx.persistBinding(buildBindingFromAgentRow(agent));
633
737
  hydrated += 1;
634
738
  } catch (err) {
635
739
  debugError('ticlawk', 'binding.hydrate-failed', {
@@ -724,7 +828,18 @@ export function createTiclawkAdapter(ctx) {
724
828
 
725
829
  const grouped = new Map();
726
830
  for (const msg of data) {
727
- const agentId = getAgentIdFromPayload(msg) || '__default__';
831
+ const agentId = getAgentIdFromPayload(msg);
832
+ if (!agentId) {
833
+ // Claim rows must carry the recipient agent id; a missing value
834
+ // is a contract violation upstream, not something we paper over
835
+ // with a synthetic key.
836
+ debugError('ticlawk', 'claim.missing-agent-id', {
837
+ reason,
838
+ deliveryId: msg?.delivery_id,
839
+ messageId: msg?.message_id,
840
+ });
841
+ continue;
842
+ }
728
843
  const bucket = grouped.get(agentId) || [];
729
844
  bucket.push(msg);
730
845
  grouped.set(agentId, bucket);
@@ -806,12 +921,46 @@ export function createTiclawkAdapter(ctx) {
806
921
  bindingsWakeTimer.unref?.();
807
922
  }
808
923
 
924
+ async function reportHostCapabilitiesNow() {
925
+ const entries = await Promise.all(Object.entries(ctx.runtimes || {})
926
+ .filter(([, runtime]) => typeof runtime?.health === 'function')
927
+ .map(async ([name, runtime]) => {
928
+ try {
929
+ return [name, await runtime.health()];
930
+ } catch (err) {
931
+ return [name, { available: false, error: err?.message || 'health probe failed' }];
932
+ }
933
+ }));
934
+ const runtimesHealth = Object.fromEntries(entries);
935
+ try {
936
+ await api.reportHostCapabilities({
937
+ hostId,
938
+ hostLabel,
939
+ runtimesHealth,
940
+ daemonVersion: api.getTiclawkVersion(),
941
+ });
942
+ debugLog('ticlawk', 'host.capabilities.reported', {
943
+ hostId,
944
+ runtimes: Object.entries(runtimesHealth)
945
+ .filter(([, v]) => v?.available)
946
+ .map(([k]) => k)
947
+ .join(','),
948
+ });
949
+ } catch (err) {
950
+ debugError('ticlawk', 'host.capabilities.failed', {
951
+ hostId,
952
+ error: err?.message || 'report failed',
953
+ });
954
+ }
955
+ }
956
+
809
957
  function handleWakeEvent(event) {
810
958
  wakeState.lastEventAt = new Date().toISOString();
811
959
  if (event?.type === 'hello') {
812
960
  void refreshBindings('wake.hello')
813
961
  .then(() => requestDrain('wake.hello'))
814
962
  .catch(() => {});
963
+ void reportHostCapabilitiesNow();
815
964
  return;
816
965
  }
817
966
  if (event?.type === 'jobs.available') {
@@ -1016,9 +1165,10 @@ export function createTiclawkAdapter(ctx) {
1016
1165
  id: 'ticlawk',
1017
1166
 
1018
1167
  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.
1168
+ // No stale-delivery recovery anywhere if the daemon is killed
1169
+ // mid-claim, the row stays `claimed` forever. lease_expires_at
1170
+ // was dropped in X1; no cron has replaced it. Rare in practice;
1171
+ // when it happens the fix is a one-row UPDATE in supabase.
1022
1172
  await refreshBindings('startup');
1023
1173
  await requestDrain('startup');
1024
1174
  connectWakeSocket();