ticlawk 0.1.16-dev.9 → 0.1.17-dev.1

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 +17 -3
  2. package/bin/ticlawk.mjs +255 -21
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +350 -50
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +248 -130
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +715 -18
  9. package/src/core/agent-cli-handlers.mjs +556 -18
  10. package/src/core/agent-home.mjs +81 -1
  11. package/src/core/argv.mjs +11 -1
  12. package/src/core/events/worker-events.mjs +32 -36
  13. package/src/core/http.mjs +152 -0
  14. package/src/core/runtime-contract.mjs +0 -1
  15. package/src/core/runtime-env.mjs +7 -0
  16. package/src/core/runtime-support.mjs +130 -78
  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-step-prompt.mjs +98 -0
  20. package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
  21. package/src/runtimes/_shared/handbook/BASICS.md +27 -0
  22. package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
  23. package/src/runtimes/_shared/handbook/COMMUNICATION.md +55 -0
  24. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  25. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +47 -0
  26. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
  27. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
  28. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
  29. package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
  30. package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
  31. package/src/runtimes/_shared/standing-prompt.mjs +124 -279
  32. package/src/runtimes/_shared/wake-prompt.mjs +268 -0
  33. package/src/runtimes/claude-code/index.mjs +19 -46
  34. package/src/runtimes/claude-code/session.mjs +2 -7
  35. package/src/runtimes/codex/index.mjs +115 -63
  36. package/src/runtimes/codex/session.mjs +2 -12
  37. package/src/runtimes/openclaw/index.mjs +11 -24
  38. package/src/runtimes/opencode/index.mjs +38 -60
  39. package/src/runtimes/opencode/session.mjs +12 -12
  40. package/src/runtimes/pi/index.mjs +38 -60
  41. package/src/runtimes/pi/session.mjs +9 -6
  42. package/ticlawk.mjs +0 -30
@@ -10,13 +10,16 @@ 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 { persistApiCredential } from './credentials.mjs';
13
+ import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
14
14
  import { TiclawkWakeClient } from './wake-client.mjs';
15
+ import { buildInboundWakePrompt } from '../../runtimes/_shared/wake-prompt.mjs';
16
+ import { buildGoalStepPrompt } from '../../runtimes/_shared/goal-step-prompt.mjs';
15
17
 
16
18
  const require = createRequire(import.meta.url);
17
19
  const qrcode = require('qrcode-terminal');
18
20
  const JOBS_WAKE_DEBOUNCE_MS = 100;
19
21
  const BINDINGS_WAKE_DEBOUNCE_MS = 500;
22
+ const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
20
23
  const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
21
24
  const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
22
25
  const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
@@ -69,98 +72,6 @@ function normalizeInboundMediaAssets(msg) {
69
72
  .filter(Boolean);
70
73
  }
71
74
 
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
75
  export function normalizeInboundMessage(msg) {
165
76
  // Claimed delivery rows carry the recipient agent id; legacy messages
166
77
  // (history sync, manual inserts) may still use plain agent_id. Prefer
@@ -169,33 +80,24 @@ export function normalizeInboundMessage(msg) {
169
80
  const messageId = msg.id || msg.message_id || null;
170
81
  const deliveryId = msg.delivery_id || null;
171
82
  const media = normalizeInboundMediaAssets(msg);
172
- const rawText = msg.text || '';
173
83
  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;
84
+ const isTransition = msg.reason === 'transition';
85
+ // A transition delivery is a goal-lane FSM step, not a chat message: it has
86
+ // no backing message, so build a per-step goal prompt from its payload.
87
+ const wakePrompt = isTransition ? buildGoalStepPrompt(enriched) : buildInboundWakePrompt(enriched);
187
88
  return {
188
89
  bindingId: recipientAgentId,
189
90
  messageId,
190
91
  deliveryId,
191
92
  conversationId: msg.conversation_id || null,
93
+ lane: isTransition ? 'goal' : 'chat',
192
94
  seq: msg.seq != null ? Number(msg.seq) : null,
193
95
  senderType: msg.sender_type || 'human',
194
- envelopeHeader: header,
195
- envelopeTarget: target,
196
- text,
197
- rawText,
198
- action: msg.action || (media.length > 0 ? 'image' : 'task'),
96
+ envelopeHeader: wakePrompt.header,
97
+ envelopeTarget: wakePrompt.target,
98
+ text: wakePrompt.text,
99
+ rawText: wakePrompt.rawText,
100
+ action: isTransition ? 'transition' : (msg.action || (media.length > 0 ? 'image' : 'task')),
199
101
  media,
200
102
  raw: {
201
103
  ...msg,
@@ -233,6 +135,19 @@ function getAgentIdFromPayload(payload) {
233
135
  return String(payload?.recipient_agent_id || '').trim();
234
136
  }
235
137
 
138
+ // Two lanes run concurrently per agent (see the lane-aware claim RPC):
139
+ // the goal lane carries FSM transition deliveries (reason='transition'),
140
+ // everything else is the user-facing chat lane. The claim unit and the
141
+ // daemon's in-flight key are the (agent, lane) channel, so a running chat
142
+ // turn and a running goal transition never block each other.
143
+ function getDeliveryLaneFromPayload(payload) {
144
+ return payload?.reason === 'transition' ? 'goal' : 'chat';
145
+ }
146
+
147
+ function channelKeyFor(agentId, lane) {
148
+ return `${agentId}:${lane}`;
149
+ }
150
+
236
151
  // Whitelist of meta keys runtimes actually consume. Anything else in
237
152
  // the source row's meta blob is dropped on the floor so stale fields
238
153
  // (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
@@ -243,6 +158,11 @@ const RUNTIME_META_KEYS = [
243
158
  'runtimePath',
244
159
  'rotatePending',
245
160
  'lastRotatedAt',
161
+ // Per-lane scoped session maps (keyed by conversation): the chat lane and
162
+ // the goal-FSM lane keep separate runtime sessions so a transition turn
163
+ // never resumes a user-chat session or vice-versa.
164
+ 'chatSessions',
165
+ 'goalSessions',
246
166
  // claude_code
247
167
  'claudePath',
248
168
  'claudeVersion',
@@ -359,6 +279,31 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
359
279
  return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
360
280
  }
361
281
 
282
+ function compareStrings(a, b) {
283
+ const av = String(a || '');
284
+ const bv = String(b || '');
285
+ if (av < bv) return -1;
286
+ if (av > bv) return 1;
287
+ return 0;
288
+ }
289
+
290
+ function compareNumbers(a, b) {
291
+ const av = Number(a);
292
+ const bv = Number(b);
293
+ const aFinite = Number.isFinite(av);
294
+ const bFinite = Number.isFinite(bv);
295
+ if (aFinite && bFinite && av !== bv) return av - bv;
296
+ if (aFinite !== bFinite) return aFinite ? -1 : 1;
297
+ return 0;
298
+ }
299
+
300
+ function compareClaimedDeliveries(a, b) {
301
+ return compareStrings(a?.created_at, b?.created_at)
302
+ || compareStrings(a?.conversation_id, b?.conversation_id)
303
+ || compareNumbers(a?.seq, b?.seq)
304
+ || compareStrings(a?.delivery_id, b?.delivery_id);
305
+ }
306
+
362
307
  function runtimeLabel(runtime) {
363
308
  if (runtime === 'claude_code') return 'Claude Code';
364
309
  if (runtime === 'opencode') return 'OpenCode';
@@ -472,8 +417,10 @@ export function createTiclawkAdapter(ctx) {
472
417
  let bindingAuditTimer = null;
473
418
  let jobsWakeTimer = null;
474
419
  let bindingsWakeTimer = null;
420
+ let credentialsWakeTimer = null;
475
421
  let lastJobsWakeAt = 0;
476
422
  let lastBindingsWakeAt = 0;
423
+ let lastCredentialsWakeAt = 0;
477
424
  let updateRequired = null;
478
425
  let lastUpdateRequiredLogAt = 0;
479
426
 
@@ -649,7 +596,7 @@ export function createTiclawkAdapter(ctx) {
649
596
 
650
597
  const binding = await ctx.persistBinding(buildBindingFromSource(msg));
651
598
  if (!binding?.runtime) {
652
- throw new Error('claimed message missing runtime binding');
599
+ throw new Error('claimed delivery missing runtime binding');
653
600
  }
654
601
  if (!belongsToRuntimeHost(binding, hostId)) {
655
602
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -717,6 +664,91 @@ export function createTiclawkAdapter(ctx) {
717
664
  }
718
665
  }
719
666
 
667
+ // The goal lane's canonical completion is `ticlawk goal report`, which
668
+ // completes the transition delivery inside report_goal_transition (before
669
+ // emitting the next step) so the live-transition slot frees up. By the time
670
+ // the turn returns the row is usually already 'completed', so this
671
+ // best-effort complete then 409s — that is the expected healthy path, not an
672
+ // error. A turn that finished without reporting leaves the row 'claimed' for
673
+ // stale-claimed recovery.
674
+ async function completeGoalDelivery(deliveryId) {
675
+ try {
676
+ await api.completeDelivery(deliveryId, hostId);
677
+ } catch (err) {
678
+ if (err?.status === 409) return;
679
+ throw err;
680
+ }
681
+ }
682
+
683
+ async function processGoalTransitionsForAgent(agentId, messages) {
684
+ for (const msg of messages) {
685
+ const deliveryId = msg.delivery_id;
686
+ const step = msg?.payload?.step || null;
687
+ try {
688
+ const messageHostId = getRuntimeHostIdFromPayload(msg);
689
+ if (messageHostId && messageHostId !== hostId) {
690
+ await api.releaseDelivery(deliveryId, hostId, 'host-mismatch');
691
+ continue;
692
+ }
693
+
694
+ const binding = await ctx.persistBinding(buildBindingFromSource(msg));
695
+ if (!binding?.runtime) {
696
+ throw new Error('claimed transition missing runtime binding');
697
+ }
698
+ if (!belongsToRuntimeHost(binding, hostId)) {
699
+ await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
700
+ continue;
701
+ }
702
+
703
+ const completed = await ctx.bus.dispatchToAgent(binding.runtime, binding.id, normalizeInboundMessage(msg));
704
+ if (completed !== true) {
705
+ if (isTerminalRuntimeFailure(completed)) {
706
+ await completeGoalDelivery(deliveryId);
707
+ void requestDrain('transition.terminal-completed');
708
+ debugError('ticlawk', 'transition.terminal-failed', {
709
+ agentId,
710
+ deliveryId,
711
+ step,
712
+ runtime: binding.runtime,
713
+ reason: completed.reason || 'runtime terminal failure',
714
+ });
715
+ continue;
716
+ }
717
+ throw new Error('runtime did not complete transition turn');
718
+ }
719
+ await completeGoalDelivery(deliveryId);
720
+ void requestDrain('transition.completed');
721
+ debugLog('ticlawk', 'transition.completed', {
722
+ agentId,
723
+ deliveryId,
724
+ step,
725
+ runtime: binding.runtime,
726
+ });
727
+ } catch (err) {
728
+ if (api.isUpdateRequiredError(err)) {
729
+ recordUpdateRequired(err, 'transition.dispatch');
730
+ }
731
+ try {
732
+ await api.releaseDelivery(deliveryId, hostId, 'transition-dispatch-error');
733
+ } catch (releaseErr) {
734
+ debugError('ticlawk', 'transition.release-failed', {
735
+ agentId,
736
+ deliveryId,
737
+ hostId,
738
+ error: releaseErr?.message || 'unknown error',
739
+ });
740
+ }
741
+ debugError('ticlawk', 'transition.dispatch-failed', {
742
+ agentId,
743
+ deliveryId,
744
+ step,
745
+ hostId,
746
+ error: err?.message || 'unknown error',
747
+ });
748
+ }
749
+ }
750
+ }
751
+
720
752
  async function refreshBindings(reason = 'manual') {
721
753
  let channels = [];
722
754
  const startedAt = Date.now();
@@ -755,6 +787,27 @@ export function createTiclawkAdapter(ctx) {
755
787
  error: err?.message || 'unknown error',
756
788
  });
757
789
  }
790
+
791
+ // Agents created from the App via POST /me/agents land here in
792
+ // status='unpaired'. Once we've successfully registered the
793
+ // binding locally, flip them to 'connected' — same end state the
794
+ // legacy QR pairing flow leaves agents in. spawn itself stays
795
+ // lazy (happens on first delivery via deliverTurn).
796
+ if (agent.status === 'unpaired' && agentHostId === hostId) {
797
+ try {
798
+ await api.updateAgent(agent.id, {
799
+ status: 'connected',
800
+ runtime_host_id: hostId,
801
+ runtime_host_label: getHostLabel(),
802
+ });
803
+ debugLog('ticlawk', 'binding.unpaired-claimed', { agentId: agent.id });
804
+ } catch (err) {
805
+ debugError('ticlawk', 'binding.unpaired-claim-failed', {
806
+ agentId: agent.id,
807
+ error: err?.message || 'unknown error',
808
+ });
809
+ }
810
+ }
758
811
  }
759
812
  const pruned = await pruneDeletedBindings(channels, reason);
760
813
  debugLog('ticlawk', 'binding.refresh-ok', {
@@ -769,6 +822,33 @@ export function createTiclawkAdapter(ctx) {
769
822
  return hydrated;
770
823
  }
771
824
 
825
+ const syncCredentials = coalesce(async (reason = 'manual') => {
826
+ const startedAt = Date.now();
827
+ try {
828
+ const payload = await api.fetchCredentials();
829
+ const credentials = Array.isArray(payload?.credentials) ? payload.credentials : [];
830
+ const result = persistRuntimeCredentials(credentials);
831
+ debugLog('ticlawk-credentials', 'sync-ok', {
832
+ reason,
833
+ saved: result.saved,
834
+ removed: result.removed,
835
+ durationMs: Date.now() - startedAt,
836
+ wakeToSyncMs: String(reason || '').startsWith('wake') && lastCredentialsWakeAt
837
+ ? Date.now() - lastCredentialsWakeAt
838
+ : null,
839
+ });
840
+ } catch (err) {
841
+ if (api.isUpdateRequiredError(err)) {
842
+ recordUpdateRequired(err, 'credentials.sync');
843
+ }
844
+ debugError('ticlawk-credentials', 'sync-failed', {
845
+ reason,
846
+ durationMs: Date.now() - startedAt,
847
+ error: err?.message || 'unknown error',
848
+ });
849
+ }
850
+ });
851
+
772
852
  async function releaseBlockedRows(agentId, messages, reason) {
773
853
  for (const msg of messages) {
774
854
  if (!msg?.delivery_id) continue;
@@ -817,7 +897,8 @@ export function createTiclawkAdapter(ctx) {
817
897
  return { failed: true, claimed: 0, launched: 0 };
818
898
  }
819
899
 
820
- const claimed = Array.isArray(data) ? data.length : 0;
900
+ const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
901
+ const claimed = orderedData.length;
821
902
  debugLog('ticlawk', 'claim.result', {
822
903
  reason,
823
904
  hostId,
@@ -836,12 +917,12 @@ export function createTiclawkAdapter(ctx) {
836
917
  });
837
918
  }
838
919
 
839
- if (!Array.isArray(data) || data.length === 0) {
920
+ if (orderedData.length === 0) {
840
921
  return { failed: false, claimed: 0, launched: 0 };
841
922
  }
842
923
 
843
924
  const grouped = new Map();
844
- for (const msg of data) {
925
+ for (const msg of orderedData) {
845
926
  const agentId = getAgentIdFromPayload(msg);
846
927
  if (!agentId) {
847
928
  // Claim rows must carry the recipient agent id; a missing value
@@ -854,33 +935,39 @@ export function createTiclawkAdapter(ctx) {
854
935
  });
855
936
  continue;
856
937
  }
857
- const bucket = grouped.get(agentId) || [];
858
- bucket.push(msg);
859
- grouped.set(agentId, bucket);
938
+ const lane = getDeliveryLaneFromPayload(msg);
939
+ const channelKey = channelKeyFor(agentId, lane);
940
+ const bucket = grouped.get(channelKey) || { agentId, lane, messages: [] };
941
+ bucket.messages.push(msg);
942
+ grouped.set(channelKey, bucket);
860
943
  }
861
944
 
862
945
  let launched = 0;
863
- for (const [agentId, messages] of grouped.entries()) {
864
- if (processingChannels.has(agentId)) {
946
+ for (const [channelKey, { agentId, lane, messages }] of grouped.entries()) {
947
+ if (processingChannels.has(channelKey)) {
865
948
  debugError('ticlawk', 'claim.blocked-claimed-rows', {
866
949
  reason,
950
+ channelKey,
867
951
  agentId,
952
+ lane,
868
953
  blockedRows: messages.length,
869
954
  });
870
955
  await releaseBlockedRows(agentId, messages, reason);
871
956
  continue;
872
957
  }
873
- const run = processPendingMessagesForAgent(agentId, messages)
958
+ const run = (lane === 'goal'
959
+ ? processGoalTransitionsForAgent(agentId, messages)
960
+ : processPendingMessagesForAgent(agentId, messages))
874
961
  .catch(() => {})
875
962
  .finally(() => {
876
- processingChannels.delete(agentId);
963
+ processingChannels.delete(channelKey);
877
964
  void requestDrain('channel.completed');
878
965
  });
879
- processingChannels.set(agentId, run);
966
+ processingChannels.set(channelKey, run);
880
967
  launched += messages.length;
881
968
  }
882
969
 
883
- return { failed: false, claimed: data.length, launched };
970
+ return { failed: false, claimed: orderedData.length, launched };
884
971
  }
885
972
 
886
973
  async function runDrain(reason) {
@@ -919,6 +1006,15 @@ export function createTiclawkAdapter(ctx) {
919
1006
  bindingsWakeTimer.unref?.();
920
1007
  }
921
1008
 
1009
+ function scheduleCredentialSync(reason) {
1010
+ credentialsWakeTimer = clearDebounce(credentialsWakeTimer);
1011
+ credentialsWakeTimer = setTimeout(() => {
1012
+ credentialsWakeTimer = null;
1013
+ void syncCredentials(reason);
1014
+ }, CREDENTIALS_WAKE_DEBOUNCE_MS);
1015
+ credentialsWakeTimer.unref?.();
1016
+ }
1017
+
922
1018
  async function reportHostCapabilitiesNow() {
923
1019
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
924
1020
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -958,6 +1054,7 @@ export function createTiclawkAdapter(ctx) {
958
1054
  void refreshBindings('wake.hello')
959
1055
  .then(() => requestDrain('wake.hello'))
960
1056
  .catch(() => {});
1057
+ void syncCredentials('wake.hello');
961
1058
  void reportHostCapabilitiesNow();
962
1059
  return;
963
1060
  }
@@ -979,6 +1076,17 @@ export function createTiclawkAdapter(ctx) {
979
1076
  scheduleRefreshAndDrain('wake.bindings.changed');
980
1077
  return;
981
1078
  }
1079
+ if (event?.type === 'credentials.changed') {
1080
+ lastCredentialsWakeAt = Date.now();
1081
+ debugLog('ticlawk-wake', 'credentials.changed', {
1082
+ credentialId: event.credential_id || null,
1083
+ name: event.name || null,
1084
+ status: event.status || null,
1085
+ reason: event.reason || null,
1086
+ });
1087
+ scheduleCredentialSync('wake.credentials.changed');
1088
+ return;
1089
+ }
982
1090
  if (event?.type === 'auth.revoked') {
983
1091
  wakeState.lastError = 'auth revoked';
984
1092
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1013,7 +1121,17 @@ export function createTiclawkAdapter(ctx) {
1013
1121
 
1014
1122
  function connectWakeSocket() {
1015
1123
  connectorSocket = new TiclawkWakeClient({
1016
- getUrl: api.getConnectorWsUrl,
1124
+ // host_id rides on the WS query string so connector-wake can flip
1125
+ // runtime_hosts.online for the right row. Empty host_id is
1126
+ // tolerated by the server (it just skips the state write).
1127
+ getUrl: () => {
1128
+ const base = String(api.getConnectorWsUrl() || '').trim();
1129
+ if (!base) return '';
1130
+ const hostId = String(getHostId() || '').trim();
1131
+ if (!hostId) return base;
1132
+ const sep = base.includes('?') ? '&' : '?';
1133
+ return `${base}${sep}host_id=${encodeURIComponent(hostId)}`;
1134
+ },
1017
1135
  getApiKey: api.getApiKey,
1018
1136
  onEvent: handleWakeEvent,
1019
1137
  onStatus: handleWakeStatus,
@@ -1163,11 +1281,11 @@ export function createTiclawkAdapter(ctx) {
1163
1281
  id: 'ticlawk',
1164
1282
 
1165
1283
  async start() {
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.
1284
+ // Stale claimed deliveries are recovered conservatively by the
1285
+ // claim RPC; this startup path only refreshes local bindings and
1286
+ // asks for the next drain.
1170
1287
  await refreshBindings('startup');
1288
+ await syncCredentials('startup');
1171
1289
  await requestDrain('startup');
1172
1290
  connectWakeSocket();
1173
1291
  startAuditTimers();
@@ -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
  }