ticlawk 0.1.16-dev.3 → 0.1.16-dev.31

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 (42) hide show
  1. package/README.md +14 -2
  2. package/bin/ticlawk.mjs +207 -25
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +293 -70
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +199 -199
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +607 -37
  9. package/src/core/agent-cli-handlers.mjs +449 -20
  10. package/src/core/agent-home.mjs +86 -10
  11. package/src/core/argv.mjs +11 -1
  12. package/src/core/events/worker-events.mjs +32 -36
  13. package/src/core/http.mjs +126 -0
  14. package/src/core/runtime-env.mjs +7 -0
  15. package/src/core/runtime-support.mjs +108 -107
  16. package/src/migrate/write-initial-memory.mjs +5 -5
  17. package/src/runtimes/_shared/agent-handbook.mjs +45 -0
  18. package/src/runtimes/_shared/brand.mjs +2 -0
  19. package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
  20. package/src/runtimes/_shared/handbook/BASICS.md +27 -0
  21. package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
  22. package/src/runtimes/_shared/handbook/COMMUNICATION.md +50 -0
  23. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  24. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +43 -0
  25. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
  26. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
  27. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
  28. package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
  29. package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
  30. package/src/runtimes/_shared/standing-prompt.mjs +111 -262
  31. package/src/runtimes/_shared/wake-prompt.mjs +261 -0
  32. package/src/runtimes/claude-code/index.mjs +34 -127
  33. package/src/runtimes/claude-code/session.mjs +2 -7
  34. package/src/runtimes/codex/index.mjs +117 -54
  35. package/src/runtimes/codex/session.mjs +2 -12
  36. package/src/runtimes/openclaw/index.mjs +16 -26
  37. package/src/runtimes/opencode/index.mjs +45 -66
  38. package/src/runtimes/opencode/session.mjs +12 -12
  39. package/src/runtimes/pi/index.mjs +42 -60
  40. package/src/runtimes/pi/session.mjs +9 -6
  41. package/src/adapters/ticlawk/cards.mjs +0 -149
  42. package/src/core/media/outbound.mjs +0 -163
@@ -10,14 +10,15 @@ 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
12
  import * as api from './api.mjs';
13
- import { processAndSaveResult } from './cards.mjs';
14
- import { persistApiCredential } from './credentials.mjs';
13
+ import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
15
14
  import { TiclawkWakeClient } from './wake-client.mjs';
15
+ import { buildInboundWakePrompt } from '../../runtimes/_shared/wake-prompt.mjs';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const qrcode = require('qrcode-terminal');
19
19
  const JOBS_WAKE_DEBOUNCE_MS = 100;
20
20
  const BINDINGS_WAKE_DEBOUNCE_MS = 500;
21
+ const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
21
22
  const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
22
23
  const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
23
24
  const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
@@ -27,6 +28,32 @@ function connectError(statusCode, error) {
27
28
  return { statusCode, body: { ok: false, error } };
28
29
  }
29
30
 
31
+ // Coalesce: returns a wrapped fn that runs at most one invocation at a
32
+ // time. If it's called while an invocation is in flight, the most recent
33
+ // args are stashed and the wrapped fn re-runs exactly once after the
34
+ // current run completes (regardless of how many times it was called
35
+ // during the run). The wrapped fn always returns the in-flight Promise.
36
+ function coalesce(fn) {
37
+ let running = null;
38
+ let pendingArgs = null;
39
+ return function call(...args) {
40
+ if (running) {
41
+ pendingArgs = args;
42
+ return running;
43
+ }
44
+ running = (async () => {
45
+ let currentArgs = args;
46
+ while (true) {
47
+ pendingArgs = null;
48
+ await fn(...currentArgs);
49
+ if (pendingArgs === null) return;
50
+ currentArgs = pendingArgs;
51
+ }
52
+ })().finally(() => { running = null; });
53
+ return running;
54
+ };
55
+ }
56
+
30
57
  function normalizeInboundMediaAssets(msg) {
31
58
  if (!Array.isArray(msg?.media_assets)) return [];
32
59
  return msg.media_assets
@@ -44,98 +71,6 @@ function normalizeInboundMediaAssets(msg) {
44
71
  .filter(Boolean);
45
72
  }
46
73
 
47
- function buildEnvelopeTarget(msg) {
48
- const convType = msg.conversation_type || 'dm';
49
- const conversationId = msg.conversation_id || '';
50
- const senderHandle = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
51
- if (convType === 'dm') {
52
- return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
53
- }
54
- if (convType === 'thread') {
55
- const groupName = msg.conversation_name || conversationId;
56
- const threadRoot = msg.thread_root_message_id || msg.message_id || '';
57
- return `#${groupName}:${threadRoot}`;
58
- }
59
- // group
60
- const groupName = msg.conversation_name || conversationId;
61
- return `#${groupName}`;
62
- }
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
-
85
- function buildEnvelopeHeader(msg) {
86
- const target = buildEnvelopeTarget(msg);
87
- const msgId = msg.id || msg.message_id || '';
88
- const seq = msg.seq != null ? msg.seq : '';
89
- const time = msg.created_at || new Date().toISOString();
90
- const type = msg.sender_type || 'human';
91
- const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
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');
137
- }
138
-
139
74
  export function normalizeInboundMessage(msg) {
140
75
  // Claimed delivery rows carry the recipient agent id; legacy messages
141
76
  // (history sync, manual inserts) may still use plain agent_id. Prefer
@@ -144,21 +79,8 @@ export function normalizeInboundMessage(msg) {
144
79
  const messageId = msg.id || msg.message_id || null;
145
80
  const deliveryId = msg.delivery_id || null;
146
81
  const media = normalizeInboundMediaAssets(msg);
147
- const rawText = msg.text || '';
148
82
  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;
83
+ const wakePrompt = buildInboundWakePrompt(enriched);
162
84
  return {
163
85
  bindingId: recipientAgentId,
164
86
  messageId,
@@ -166,10 +88,10 @@ export function normalizeInboundMessage(msg) {
166
88
  conversationId: msg.conversation_id || null,
167
89
  seq: msg.seq != null ? Number(msg.seq) : null,
168
90
  senderType: msg.sender_type || 'human',
169
- envelopeHeader: header,
170
- envelopeTarget: target,
171
- text,
172
- rawText,
91
+ envelopeHeader: wakePrompt.header,
92
+ envelopeTarget: wakePrompt.target,
93
+ text: wakePrompt.text,
94
+ rawText: wakePrompt.rawText,
173
95
  action: msg.action || (media.length > 0 ? 'image' : 'task'),
174
96
  media,
175
97
  raw: {
@@ -179,10 +101,6 @@ export function normalizeInboundMessage(msg) {
179
101
  };
180
102
  }
181
103
 
182
- function getBindingSessionId(binding) {
183
- return binding?.runtimeMeta?.sessionId || binding?.runtimeMeta?.sessionKey || binding?.runtimeMeta?.agentId || binding?.id || null;
184
- }
185
-
186
104
  function getRuntimeVersion(bindingOrMeta) {
187
105
  const value = bindingOrMeta?.runtimeMeta?.runtimeVersion ?? bindingOrMeta?.runtimeVersion ?? bindingOrMeta?.agent_runtime_version ?? null;
188
106
  return Number.isInteger(value) ? Number(value) : null;
@@ -222,6 +140,7 @@ const RUNTIME_META_KEYS = [
222
140
  'runtimePath',
223
141
  'rotatePending',
224
142
  'lastRotatedAt',
143
+ 'conversationSessions',
225
144
  // claude_code
226
145
  'claudePath',
227
146
  'claudeVersion',
@@ -257,51 +176,46 @@ function normalizeRuntimeVersion(value) {
257
176
  return Number.isInteger(value) ? Number(value) : 0;
258
177
  }
259
178
 
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;
179
+ // Build a binding from any source row that carries agent metadata.
180
+ // Two source shapes feed in:
181
+ // /api/agents row — id, service_type, meta,
182
+ // claim_pending_deliveries — recipient_agent_id, agent_service_type,
183
+ // agent_meta, (prefixed because the
184
+ // delivery row also carries message fields)
185
+ // We try the prefixed names first, then fall through to the bare names,
186
+ // so one builder handles both without the caller having to remember
187
+ // which shape it has.
188
+ function buildBindingFromSource(source) {
189
+ const agentId = String(
190
+ source?.recipient_agent_id
191
+ || source?.id
192
+ || source?.agent_id
193
+ || ''
194
+ ).trim();
195
+ const runtime = source?.agent_service_type || source?.service_type;
196
+ const meta = source?.agent_meta ?? source?.meta;
197
+ const runtimeVersion = source?.agent_runtime_version ?? source?.runtime_version;
198
+ const displayName = source?.agent_display_name
199
+ || source?.agent_name
200
+ || source?.display_name
201
+ || source?.name
202
+ || agentId;
203
+ const status = source?.agent_status || source?.status || 'connected';
204
+ const hostId = getRuntimeHostIdFromPayload(source) || undefined;
267
205
  return {
268
206
  id: agentId,
269
207
  adapter: 'ticlawk',
270
208
  targetKey: agentId,
271
209
  targetMeta: { agentId, runtime_host_id: hostId },
272
210
  runtime_host_id: hostId,
273
- runtime_host_label: getRuntimeHostLabelFromPayload(agent) || undefined,
211
+ runtime_host_label: getRuntimeHostLabelFromPayload(source) || undefined,
274
212
  runtime,
275
213
  runtimeMeta: {
276
- ...projectRuntimeMeta(agent?.meta),
277
- runtimeVersion: normalizeRuntimeVersion(agent?.runtime_version),
214
+ ...projectRuntimeMeta(meta),
215
+ runtimeVersion: normalizeRuntimeVersion(runtimeVersion),
278
216
  },
279
- displayName: agent?.display_name || agent?.name || agentId,
280
- status: agent?.status || 'connected',
281
- };
282
- }
283
-
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',
217
+ displayName,
218
+ status,
305
219
  };
306
220
  }
307
221
 
@@ -343,6 +257,31 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
343
257
  return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
344
258
  }
345
259
 
260
+ function compareStrings(a, b) {
261
+ const av = String(a || '');
262
+ const bv = String(b || '');
263
+ if (av < bv) return -1;
264
+ if (av > bv) return 1;
265
+ return 0;
266
+ }
267
+
268
+ function compareNumbers(a, b) {
269
+ const av = Number(a);
270
+ const bv = Number(b);
271
+ const aFinite = Number.isFinite(av);
272
+ const bFinite = Number.isFinite(bv);
273
+ if (aFinite && bFinite && av !== bv) return av - bv;
274
+ if (aFinite !== bFinite) return aFinite ? -1 : 1;
275
+ return 0;
276
+ }
277
+
278
+ function compareClaimedDeliveries(a, b) {
279
+ return compareStrings(a?.created_at, b?.created_at)
280
+ || compareStrings(a?.conversation_id, b?.conversation_id)
281
+ || compareNumbers(a?.seq, b?.seq)
282
+ || compareStrings(a?.delivery_id, b?.delivery_id);
283
+ }
284
+
346
285
  function runtimeLabel(runtime) {
347
286
  if (runtime === 'claude_code') return 'Claude Code';
348
287
  if (runtime === 'opencode') return 'OpenCode';
@@ -456,10 +395,10 @@ export function createTiclawkAdapter(ctx) {
456
395
  let bindingAuditTimer = null;
457
396
  let jobsWakeTimer = null;
458
397
  let bindingsWakeTimer = null;
459
- let drainPromise = null;
460
- let drainRequested = false;
398
+ let credentialsWakeTimer = null;
461
399
  let lastJobsWakeAt = 0;
462
400
  let lastBindingsWakeAt = 0;
401
+ let lastCredentialsWakeAt = 0;
463
402
  let updateRequired = null;
464
403
  let lastUpdateRequiredLogAt = 0;
465
404
 
@@ -633,9 +572,9 @@ export function createTiclawkAdapter(ctx) {
633
572
  continue;
634
573
  }
635
574
 
636
- const binding = await ctx.persistBinding(buildBindingFromDeliveryRow(msg));
575
+ const binding = await ctx.persistBinding(buildBindingFromSource(msg));
637
576
  if (!binding?.runtime) {
638
- throw new Error('claimed message missing runtime binding');
577
+ throw new Error('claimed delivery missing runtime binding');
639
578
  }
640
579
  if (!belongsToRuntimeHost(binding, hostId)) {
641
580
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -733,7 +672,7 @@ export function createTiclawkAdapter(ctx) {
733
672
  continue;
734
673
  }
735
674
  try {
736
- await ctx.persistBinding(buildBindingFromAgentRow(agent));
675
+ await ctx.persistBinding(buildBindingFromSource(agent));
737
676
  hydrated += 1;
738
677
  } catch (err) {
739
678
  debugError('ticlawk', 'binding.hydrate-failed', {
@@ -741,6 +680,27 @@ export function createTiclawkAdapter(ctx) {
741
680
  error: err?.message || 'unknown error',
742
681
  });
743
682
  }
683
+
684
+ // Agents created from the App via POST /me/agents land here in
685
+ // status='unpaired'. Once we've successfully registered the
686
+ // binding locally, flip them to 'connected' — same end state the
687
+ // legacy QR pairing flow leaves agents in. spawn itself stays
688
+ // lazy (happens on first delivery via deliverTurn).
689
+ if (agent.status === 'unpaired' && agentHostId === hostId) {
690
+ try {
691
+ await api.updateAgent(agent.id, {
692
+ status: 'connected',
693
+ runtime_host_id: hostId,
694
+ runtime_host_label: getHostLabel(),
695
+ });
696
+ debugLog('ticlawk', 'binding.unpaired-claimed', { agentId: agent.id });
697
+ } catch (err) {
698
+ debugError('ticlawk', 'binding.unpaired-claim-failed', {
699
+ agentId: agent.id,
700
+ error: err?.message || 'unknown error',
701
+ });
702
+ }
703
+ }
744
704
  }
745
705
  const pruned = await pruneDeletedBindings(channels, reason);
746
706
  debugLog('ticlawk', 'binding.refresh-ok', {
@@ -755,6 +715,33 @@ export function createTiclawkAdapter(ctx) {
755
715
  return hydrated;
756
716
  }
757
717
 
718
+ const syncCredentials = coalesce(async (reason = 'manual') => {
719
+ const startedAt = Date.now();
720
+ try {
721
+ const payload = await api.fetchCredentials();
722
+ const credentials = Array.isArray(payload?.credentials) ? payload.credentials : [];
723
+ const result = persistRuntimeCredentials(credentials);
724
+ debugLog('ticlawk-credentials', 'sync-ok', {
725
+ reason,
726
+ saved: result.saved,
727
+ removed: result.removed,
728
+ durationMs: Date.now() - startedAt,
729
+ wakeToSyncMs: String(reason || '').startsWith('wake') && lastCredentialsWakeAt
730
+ ? Date.now() - lastCredentialsWakeAt
731
+ : null,
732
+ });
733
+ } catch (err) {
734
+ if (api.isUpdateRequiredError(err)) {
735
+ recordUpdateRequired(err, 'credentials.sync');
736
+ }
737
+ debugError('ticlawk-credentials', 'sync-failed', {
738
+ reason,
739
+ durationMs: Date.now() - startedAt,
740
+ error: err?.message || 'unknown error',
741
+ });
742
+ }
743
+ });
744
+
758
745
  async function releaseBlockedRows(agentId, messages, reason) {
759
746
  for (const msg of messages) {
760
747
  if (!msg?.delivery_id) continue;
@@ -803,7 +790,8 @@ export function createTiclawkAdapter(ctx) {
803
790
  return { failed: true, claimed: 0, launched: 0 };
804
791
  }
805
792
 
806
- const claimed = Array.isArray(data) ? data.length : 0;
793
+ const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
794
+ const claimed = orderedData.length;
807
795
  debugLog('ticlawk', 'claim.result', {
808
796
  reason,
809
797
  hostId,
@@ -822,12 +810,12 @@ export function createTiclawkAdapter(ctx) {
822
810
  });
823
811
  }
824
812
 
825
- if (!Array.isArray(data) || data.length === 0) {
813
+ if (orderedData.length === 0) {
826
814
  return { failed: false, claimed: 0, launched: 0 };
827
815
  }
828
816
 
829
817
  const grouped = new Map();
830
- for (const msg of data) {
818
+ for (const msg of orderedData) {
831
819
  const agentId = getAgentIdFromPayload(msg);
832
820
  if (!agentId) {
833
821
  // Claim rows must carry the recipient agent id; a missing value
@@ -866,7 +854,7 @@ export function createTiclawkAdapter(ctx) {
866
854
  launched += messages.length;
867
855
  }
868
856
 
869
- return { failed: false, claimed: data.length, launched };
857
+ return { failed: false, claimed: orderedData.length, launched };
870
858
  }
871
859
 
872
860
  async function runDrain(reason) {
@@ -883,23 +871,7 @@ export function createTiclawkAdapter(ctx) {
883
871
  return { totalClaimed, iterations };
884
872
  }
885
873
 
886
- function requestDrain(reason) {
887
- if (drainPromise) {
888
- drainRequested = true;
889
- return drainPromise;
890
- }
891
- drainPromise = (async () => {
892
- let currentReason = reason;
893
- do {
894
- drainRequested = false;
895
- await runDrain(currentReason);
896
- currentReason = 'drain.requested-again';
897
- } while (drainRequested);
898
- })().finally(() => {
899
- drainPromise = null;
900
- });
901
- return drainPromise;
902
- }
874
+ const requestDrain = coalesce(runDrain);
903
875
 
904
876
  function scheduleDrain(reason) {
905
877
  jobsWakeTimer = clearDebounce(jobsWakeTimer);
@@ -921,6 +893,15 @@ export function createTiclawkAdapter(ctx) {
921
893
  bindingsWakeTimer.unref?.();
922
894
  }
923
895
 
896
+ function scheduleCredentialSync(reason) {
897
+ credentialsWakeTimer = clearDebounce(credentialsWakeTimer);
898
+ credentialsWakeTimer = setTimeout(() => {
899
+ credentialsWakeTimer = null;
900
+ void syncCredentials(reason);
901
+ }, CREDENTIALS_WAKE_DEBOUNCE_MS);
902
+ credentialsWakeTimer.unref?.();
903
+ }
904
+
924
905
  async function reportHostCapabilitiesNow() {
925
906
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
926
907
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -960,6 +941,7 @@ export function createTiclawkAdapter(ctx) {
960
941
  void refreshBindings('wake.hello')
961
942
  .then(() => requestDrain('wake.hello'))
962
943
  .catch(() => {});
944
+ void syncCredentials('wake.hello');
963
945
  void reportHostCapabilitiesNow();
964
946
  return;
965
947
  }
@@ -981,6 +963,17 @@ export function createTiclawkAdapter(ctx) {
981
963
  scheduleRefreshAndDrain('wake.bindings.changed');
982
964
  return;
983
965
  }
966
+ if (event?.type === 'credentials.changed') {
967
+ lastCredentialsWakeAt = Date.now();
968
+ debugLog('ticlawk-wake', 'credentials.changed', {
969
+ credentialId: event.credential_id || null,
970
+ name: event.name || null,
971
+ status: event.status || null,
972
+ reason: event.reason || null,
973
+ });
974
+ scheduleCredentialSync('wake.credentials.changed');
975
+ return;
976
+ }
984
977
  if (event?.type === 'auth.revoked') {
985
978
  wakeState.lastError = 'auth revoked';
986
979
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1015,7 +1008,17 @@ export function createTiclawkAdapter(ctx) {
1015
1008
 
1016
1009
  function connectWakeSocket() {
1017
1010
  connectorSocket = new TiclawkWakeClient({
1018
- getUrl: api.getConnectorWsUrl,
1011
+ // host_id rides on the WS query string so connector-wake can flip
1012
+ // runtime_hosts.online for the right row. Empty host_id is
1013
+ // tolerated by the server (it just skips the state write).
1014
+ getUrl: () => {
1015
+ const base = String(api.getConnectorWsUrl() || '').trim();
1016
+ if (!base) return '';
1017
+ const hostId = String(getHostId() || '').trim();
1018
+ if (!hostId) return base;
1019
+ const sep = base.includes('?') ? '&' : '?';
1020
+ return `${base}${sep}host_id=${encodeURIComponent(hostId)}`;
1021
+ },
1019
1022
  getApiKey: api.getApiKey,
1020
1023
  onEvent: handleWakeEvent,
1021
1024
  onStatus: handleWakeStatus,
@@ -1165,11 +1168,11 @@ export function createTiclawkAdapter(ctx) {
1165
1168
  id: 'ticlawk',
1166
1169
 
1167
1170
  async start() {
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.
1171
+ // Stale claimed deliveries are recovered conservatively by the
1172
+ // claim RPC; this startup path only refreshes local bindings and
1173
+ // asks for the next drain.
1172
1174
  await refreshBindings('startup');
1175
+ await syncCredentials('startup');
1173
1176
  await requestDrain('startup');
1174
1177
  connectWakeSocket();
1175
1178
  startAuditTimers();
@@ -1338,22 +1341,19 @@ export function createTiclawkAdapter(ctx) {
1338
1341
  }
1339
1342
  },
1340
1343
 
1341
- async send(binding, outbound) {
1342
- const localMediaPaths = (outbound.media || [])
1343
- .filter((item) => item.kind === 'local_path')
1344
- .map((item) => item.value);
1345
- await processAndSaveResult({
1346
- text: outbound.text || '',
1347
- mediaUrls: localMediaPaths,
1348
- }, {
1349
- agentId: binding.id,
1350
- hostId,
1351
- agent: binding.runtime,
1352
- sessionId: getBindingSessionId(binding),
1353
- runtimeVersion: getRuntimeVersion(binding),
1354
- turnId: outbound.turnId || outbound.replyToMessageId || null,
1355
- type: outbound.type || 'agent_message',
1356
- replyToMessageId: outbound.replyToMessageId || null,
1344
+ // Daemon-driven impersonated reply: the runtime never reached
1345
+ // `ticlawk message send` (subprocess died, timeout, etc.), so the
1346
+ // daemon speaks for the agent. Used by reportSubprocessFailure
1347
+ // with visibility='admin' so the notice only reaches owners.
1348
+ async postAgentReply(binding, { conversationId, text, replyToMessageId, visibility }) {
1349
+ if (!conversationId || !text) return null;
1350
+ return api.sendAgentMessage({
1351
+ actingAgentId: binding.id,
1352
+ conversationId,
1353
+ text,
1354
+ replyToMessageId: replyToMessageId || null,
1355
+ runtimeHostId: hostId,
1356
+ visibility: visibility || null,
1357
1357
  });
1358
1358
  },
1359
1359
 
@@ -165,7 +165,7 @@ export class TiclawkWakeClient {
165
165
  return;
166
166
  }
167
167
 
168
- if (type === 'jobs.available' || type === 'bindings.changed') {
168
+ if (type === 'jobs.available' || type === 'bindings.changed' || type === 'credentials.changed') {
169
169
  this.onEvent?.(msg);
170
170
  return;
171
171
  }