ticlawk 0.1.17-dev.2 → 0.1.17-dev.21

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 (47) hide show
  1. package/README.md +26 -59
  2. package/bin/ticlawk.mjs +31 -301
  3. package/package.json +4 -2
  4. package/scripts/publish-dev.sh +77 -0
  5. package/src/adapters/ticlawk/api.mjs +50 -378
  6. package/src/adapters/ticlawk/credentials.mjs +1 -43
  7. package/src/adapters/ticlawk/index.mjs +61 -565
  8. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  9. package/src/cli/agent-commands.mjs +18 -715
  10. package/src/core/adapter-registry.mjs +1 -19
  11. package/src/core/agent-cli-handlers.mjs +18 -556
  12. package/src/core/agent-home.mjs +1 -81
  13. package/src/core/daemon-install.mjs +5 -7
  14. package/src/core/events/worker-events.mjs +36 -32
  15. package/src/core/http.mjs +0 -152
  16. package/src/core/profiles.mjs +0 -1
  17. package/src/core/runtime-contract.mjs +1 -0
  18. package/src/core/runtime-env.mjs +0 -8
  19. package/src/core/runtime-support.mjs +78 -130
  20. package/src/runtimes/_shared/incoming-message-prompt.mjs +232 -0
  21. package/src/runtimes/_shared/runtime-base-instructions.mjs +34 -0
  22. package/src/runtimes/claude-code/index.mjs +48 -21
  23. package/src/runtimes/claude-code/session.mjs +7 -2
  24. package/src/runtimes/codex/index.mjs +64 -116
  25. package/src/runtimes/codex/session.mjs +12 -2
  26. package/src/runtimes/openclaw/index.mjs +30 -17
  27. package/src/runtimes/opencode/index.mjs +64 -42
  28. package/src/runtimes/opencode/session.mjs +14 -14
  29. package/src/runtimes/pi/index.mjs +64 -42
  30. package/src/runtimes/pi/session.mjs +8 -11
  31. package/ticlawk.mjs +32 -5
  32. package/src/runtimes/_shared/agent-handbook.mjs +0 -45
  33. package/src/runtimes/_shared/brand.mjs +0 -2
  34. package/src/runtimes/_shared/goal-step-prompt.mjs +0 -98
  35. package/src/runtimes/_shared/goal-task-protocol.mjs +0 -50
  36. package/src/runtimes/_shared/handbook/BASICS.md +0 -27
  37. package/src/runtimes/_shared/handbook/COLLABORATION.md +0 -37
  38. package/src/runtimes/_shared/handbook/COMMUNICATION.md +0 -55
  39. package/src/runtimes/_shared/handbook/DM_SCOPE.md +0 -13
  40. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +0 -47
  41. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +0 -43
  42. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +0 -21
  43. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +0 -15
  44. package/src/runtimes/_shared/handbook/SURFACES.md +0 -41
  45. package/src/runtimes/_shared/handbook/TASK_WORKER.md +0 -14
  46. package/src/runtimes/_shared/standing-prompt.mjs +0 -171
  47. package/src/runtimes/_shared/wake-prompt.mjs +0 -268
@@ -1,25 +1,21 @@
1
- import { parseOptionArgs } from '../../core/argv.mjs';
2
1
  import { createHash, randomBytes } from 'node:crypto';
3
2
  import { createRequire } from 'node:module';
4
- import { basename } from 'node:path';
5
3
  import { loadPersistentConfig, persistConfig, TICLAWK_CONNECTOR_API_KEY, TICLAWK_CONNECTOR_WS_URL } from '../../core/config.mjs';
6
4
  import { belongsToRuntimeHost, getBindingRuntimeHostId, getHostId, getHostLabel } from '../../core/host-id.mjs';
7
5
  import { debugError, debugLog } from '../../core/logger.mjs';
8
- import { getActiveProfile, ensureLegacyProfile, readProfileMeta, saveAndActivateProfile } from '../../core/profiles.mjs';
6
+ import { getActiveProfile, saveAndActivateProfile } from '../../core/profiles.mjs';
9
7
  import { isTerminalRuntimeFailure } from '../../core/runtime-support.mjs';
10
8
  import { clearUpdateRequiredState, readUpdateState, setUpdateRequiredState } from '../../core/update-state.mjs';
11
9
  import { isManagedInstall, startDetachedSelfUpdate } from '../../core/update.mjs';
10
+ import { buildIncomingMessagePrompt } from '../../runtimes/_shared/incoming-message-prompt.mjs';
12
11
  import * as api from './api.mjs';
13
- import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
12
+ import { persistApiCredential } from './credentials.mjs';
14
13
  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';
17
14
 
18
15
  const require = createRequire(import.meta.url);
19
16
  const qrcode = require('qrcode-terminal');
20
17
  const JOBS_WAKE_DEBOUNCE_MS = 100;
21
18
  const BINDINGS_WAKE_DEBOUNCE_MS = 500;
22
- const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
23
19
  const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
24
20
  const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
25
21
  const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
@@ -73,35 +69,28 @@ function normalizeInboundMediaAssets(msg) {
73
69
  }
74
70
 
75
71
  export function normalizeInboundMessage(msg) {
76
- // Claimed delivery rows carry the recipient agent id; legacy messages
77
- // (history sync, manual inserts) may still use plain agent_id. Prefer
78
- // the new field but fall back so the same normalizer works for both.
79
72
  const recipientAgentId = msg.recipient_agent_id || '';
80
73
  const messageId = msg.id || msg.message_id || null;
81
74
  const deliveryId = msg.delivery_id || null;
82
75
  const media = normalizeInboundMediaAssets(msg);
83
- const enriched = { ...msg, id: messageId };
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);
76
+ const prompt = buildIncomingMessagePrompt({ ...msg, id: messageId });
88
77
  return {
89
78
  bindingId: recipientAgentId,
90
79
  messageId,
91
80
  deliveryId,
92
81
  conversationId: msg.conversation_id || null,
93
- lane: isTransition ? 'goal' : 'chat',
94
82
  seq: msg.seq != null ? Number(msg.seq) : null,
95
83
  senderType: msg.sender_type || 'human',
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')),
84
+ envelopeHeader: prompt.header,
85
+ envelopeTarget: prompt.target,
86
+ text: prompt.text,
87
+ rawText: prompt.rawText,
88
+ action: msg.action || (media.length > 0 ? 'image' : 'task'),
101
89
  media,
102
90
  raw: {
103
91
  ...msg,
104
92
  id: messageId,
93
+ type: prompt.type,
105
94
  },
106
95
  };
107
96
  }
@@ -129,40 +118,18 @@ function getRuntimeHostLabelFromPayload(payload) {
129
118
 
130
119
  function getAgentIdFromPayload(payload) {
131
120
  // claim_pending_deliveries is the canonical source and returns
132
- // `recipient_agent_id`. The historical fallbacks (`agent_id`,
133
- // `agentId`) were left in by an earlier partial cleanup but they
134
- // never fire against the current schema — dead branches.
121
+ // `recipient_agent_id`.
135
122
  return String(payload?.recipient_agent_id || '').trim();
136
123
  }
137
124
 
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
-
151
125
  // Whitelist of meta keys runtimes actually consume. Anything else in
152
- // the source row's meta blob is dropped on the floor so stale fields
153
- // (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
154
- // back into runtimeMeta and confuse downstream code.
126
+ // the source row's meta blob is dropped before reaching runtimeMeta.
155
127
  const RUNTIME_META_KEYS = [
156
128
  'sessionId',
157
129
  'path',
158
130
  'runtimePath',
159
131
  'rotatePending',
160
132
  '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',
166
133
  // claude_code
167
134
  'claudePath',
168
135
  'claudeVersion',
@@ -249,24 +216,6 @@ function maskIdentity(identity = {}) {
249
216
  };
250
217
  }
251
218
 
252
- function formatIdentityLines(identity = {}) {
253
- const normalized = maskIdentity(identity);
254
- return [
255
- ` user: ${normalized.userId || 'unknown'}`,
256
- normalized.emailMasked ? ` email: ${normalized.emailMasked}` : null,
257
- normalized.phoneMasked ? ` phone: ${normalized.phoneMasked}` : null,
258
- ].filter(Boolean);
259
- }
260
-
261
- function formatAffectedAgents(agents = []) {
262
- if (!agents.length) return [' - none found for this host'];
263
- return agents.map((agent) => {
264
- const display = agent.display_name || agent.name || agent.id;
265
- const runtime = agent.service_type || 'unknown';
266
- return ` - ${display} (${runtime}) ${agent.id}`;
267
- });
268
- }
269
-
270
219
  function sha256Hex(value) {
271
220
  return createHash('sha256').update(String(value)).digest('hex');
272
221
  }
@@ -275,88 +224,6 @@ function makeClientSecret() {
275
224
  return randomBytes(32).toString('base64url');
276
225
  }
277
226
 
278
- function getRuntimeWorkdir(runtimeMeta = {}) {
279
- return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
280
- }
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
-
307
- function runtimeLabel(runtime) {
308
- if (runtime === 'claude_code') return 'Claude Code';
309
- if (runtime === 'opencode') return 'OpenCode';
310
- if (runtime === 'openclaw') return 'OpenClaw';
311
- if (runtime === 'codex') return 'Codex';
312
- if (runtime === 'pi') return 'Pi';
313
- return runtime || 'Agent';
314
- }
315
-
316
- function printDetectedRuntimeOptions(runtimeOptions = []) {
317
- console.log('Detected agent harness:');
318
- if (!runtimeOptions.length) {
319
- console.log(' none');
320
- return;
321
- }
322
- for (const option of runtimeOptions) {
323
- const label = option.runtime_label || runtimeLabel(option.runtime);
324
- const workdir = option.workdir ? ` (${option.workdir})` : '';
325
- console.log(` - ${label}${workdir}`);
326
- }
327
- }
328
-
329
- async function buildAutoRuntimeOptions(ctx, payload = {}) {
330
- const workdir = getRuntimeWorkdir(payload) || process.cwd();
331
- const candidates = ['codex', 'claude_code', 'opencode', 'pi'];
332
- const options = [];
333
- for (const serviceType of candidates) {
334
- try {
335
- const resolved = await ctx.resolveRuntimeBinding({
336
- ...payload,
337
- serviceType,
338
- workdir,
339
- });
340
- const runtimeMeta = resolved.runtimeMeta || {};
341
- const optionWorkdir = getRuntimeWorkdir(runtimeMeta) || workdir;
342
- options.push({
343
- runtime: resolved.runtime,
344
- runtime_label: runtimeLabel(resolved.runtime),
345
- display_name: resolved.displayName || basename(optionWorkdir) || runtimeLabel(resolved.runtime),
346
- workdir: optionWorkdir,
347
- binding_key: optionWorkdir,
348
- runtime_meta: runtimeMeta,
349
- });
350
- } catch (err) {
351
- debugLog('ticlawk-pairing', 'auto-runtime.skip', {
352
- runtime: serviceType,
353
- reason: err?.message || String(err),
354
- });
355
- }
356
- }
357
- return options;
358
- }
359
-
360
227
  function printPairingChallenge(session) {
361
228
  console.log();
362
229
  console.log('Open Ticlawk and scan the QR code below, or enter the pairing code.');
@@ -417,12 +284,11 @@ export function createTiclawkAdapter(ctx) {
417
284
  let bindingAuditTimer = null;
418
285
  let jobsWakeTimer = null;
419
286
  let bindingsWakeTimer = null;
420
- let credentialsWakeTimer = null;
421
287
  let lastJobsWakeAt = 0;
422
288
  let lastBindingsWakeAt = 0;
423
- let lastCredentialsWakeAt = 0;
424
289
  let updateRequired = null;
425
290
  let lastUpdateRequiredLogAt = 0;
291
+ let startupSyncing = false;
426
292
 
427
293
  function clearDebounce(timer) {
428
294
  if (timer) clearTimeout(timer);
@@ -596,7 +462,7 @@ export function createTiclawkAdapter(ctx) {
596
462
 
597
463
  const binding = await ctx.persistBinding(buildBindingFromSource(msg));
598
464
  if (!binding?.runtime) {
599
- throw new Error('claimed delivery missing runtime binding');
465
+ throw new Error('claimed message missing runtime binding');
600
466
  }
601
467
  if (!belongsToRuntimeHost(binding, hostId)) {
602
468
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -664,91 +530,6 @@ export function createTiclawkAdapter(ctx) {
664
530
  }
665
531
  }
666
532
 
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
-
752
533
  async function refreshBindings(reason = 'manual') {
753
534
  let channels = [];
754
535
  const startedAt = Date.now();
@@ -787,27 +568,6 @@ export function createTiclawkAdapter(ctx) {
787
568
  error: err?.message || 'unknown error',
788
569
  });
789
570
  }
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
- }
811
571
  }
812
572
  const pruned = await pruneDeletedBindings(channels, reason);
813
573
  debugLog('ticlawk', 'binding.refresh-ok', {
@@ -822,33 +582,6 @@ export function createTiclawkAdapter(ctx) {
822
582
  return hydrated;
823
583
  }
824
584
 
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
-
852
585
  async function releaseBlockedRows(agentId, messages, reason) {
853
586
  for (const msg of messages) {
854
587
  if (!msg?.delivery_id) continue;
@@ -897,8 +630,7 @@ export function createTiclawkAdapter(ctx) {
897
630
  return { failed: true, claimed: 0, launched: 0 };
898
631
  }
899
632
 
900
- const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
901
- const claimed = orderedData.length;
633
+ const claimed = Array.isArray(data) ? data.length : 0;
902
634
  debugLog('ticlawk', 'claim.result', {
903
635
  reason,
904
636
  hostId,
@@ -917,12 +649,12 @@ export function createTiclawkAdapter(ctx) {
917
649
  });
918
650
  }
919
651
 
920
- if (orderedData.length === 0) {
652
+ if (!Array.isArray(data) || data.length === 0) {
921
653
  return { failed: false, claimed: 0, launched: 0 };
922
654
  }
923
655
 
924
656
  const grouped = new Map();
925
- for (const msg of orderedData) {
657
+ for (const msg of data) {
926
658
  const agentId = getAgentIdFromPayload(msg);
927
659
  if (!agentId) {
928
660
  // Claim rows must carry the recipient agent id; a missing value
@@ -935,39 +667,33 @@ export function createTiclawkAdapter(ctx) {
935
667
  });
936
668
  continue;
937
669
  }
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);
670
+ const bucket = grouped.get(agentId) || [];
671
+ bucket.push(msg);
672
+ grouped.set(agentId, bucket);
943
673
  }
944
674
 
945
675
  let launched = 0;
946
- for (const [channelKey, { agentId, lane, messages }] of grouped.entries()) {
947
- if (processingChannels.has(channelKey)) {
676
+ for (const [agentId, messages] of grouped.entries()) {
677
+ if (processingChannels.has(agentId)) {
948
678
  debugError('ticlawk', 'claim.blocked-claimed-rows', {
949
679
  reason,
950
- channelKey,
951
680
  agentId,
952
- lane,
953
681
  blockedRows: messages.length,
954
682
  });
955
683
  await releaseBlockedRows(agentId, messages, reason);
956
684
  continue;
957
685
  }
958
- const run = (lane === 'goal'
959
- ? processGoalTransitionsForAgent(agentId, messages)
960
- : processPendingMessagesForAgent(agentId, messages))
686
+ const run = processPendingMessagesForAgent(agentId, messages)
961
687
  .catch(() => {})
962
688
  .finally(() => {
963
- processingChannels.delete(channelKey);
689
+ processingChannels.delete(agentId);
964
690
  void requestDrain('channel.completed');
965
691
  });
966
- processingChannels.set(channelKey, run);
692
+ processingChannels.set(agentId, run);
967
693
  launched += messages.length;
968
694
  }
969
695
 
970
- return { failed: false, claimed: orderedData.length, launched };
696
+ return { failed: false, claimed: data.length, launched };
971
697
  }
972
698
 
973
699
  async function runDrain(reason) {
@@ -1006,15 +732,6 @@ export function createTiclawkAdapter(ctx) {
1006
732
  bindingsWakeTimer.unref?.();
1007
733
  }
1008
734
 
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
-
1018
735
  async function reportHostCapabilitiesNow() {
1019
736
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
1020
737
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -1051,11 +768,11 @@ export function createTiclawkAdapter(ctx) {
1051
768
  function handleWakeEvent(event) {
1052
769
  wakeState.lastEventAt = new Date().toISOString();
1053
770
  if (event?.type === 'hello') {
771
+ void reportHostCapabilitiesNow();
772
+ if (startupSyncing) return;
1054
773
  void refreshBindings('wake.hello')
1055
774
  .then(() => requestDrain('wake.hello'))
1056
775
  .catch(() => {});
1057
- void syncCredentials('wake.hello');
1058
- void reportHostCapabilitiesNow();
1059
776
  return;
1060
777
  }
1061
778
  if (event?.type === 'jobs.available') {
@@ -1076,17 +793,6 @@ export function createTiclawkAdapter(ctx) {
1076
793
  scheduleRefreshAndDrain('wake.bindings.changed');
1077
794
  return;
1078
795
  }
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
- }
1090
796
  if (event?.type === 'auth.revoked') {
1091
797
  wakeState.lastError = 'auth revoked';
1092
798
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1119,19 +825,21 @@ export function createTiclawkAdapter(ctx) {
1119
825
  });
1120
826
  }
1121
827
 
828
+ function getConnectorWakeUrlForHost() {
829
+ const raw = api.getConnectorWsUrl();
830
+ try {
831
+ const url = new URL(raw);
832
+ url.searchParams.set('runtime_host_id', hostId);
833
+ return url.toString();
834
+ } catch {
835
+ const separator = raw.includes('?') ? '&' : '?';
836
+ return `${raw}${separator}runtime_host_id=${encodeURIComponent(hostId)}`;
837
+ }
838
+ }
839
+
1122
840
  function connectWakeSocket() {
1123
841
  connectorSocket = new TiclawkWakeClient({
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
- },
842
+ getUrl: getConnectorWakeUrlForHost,
1135
843
  getApiKey: api.getApiKey,
1136
844
  onEvent: handleWakeEvent,
1137
845
  onStatus: handleWakeStatus,
@@ -1159,7 +867,7 @@ export function createTiclawkAdapter(ctx) {
1159
867
  bindingAuditTimer.unref?.();
1160
868
  }
1161
869
 
1162
- async function finishPairing(resolved, pairData) {
870
+ async function finishHostPairing(pairData) {
1163
871
  const apiKey = pairData.connector_api_key || pairData.apiKey || pairData.api_key;
1164
872
  persistApiCredential(apiKey);
1165
873
  if (pairData.connector_ws_url) {
@@ -1178,61 +886,26 @@ export function createTiclawkAdapter(ctx) {
1178
886
  restartWakeSocket('connect.paired');
1179
887
  }
1180
888
 
1181
- const bindingId = pairData.agent_id || pairData.agentId;
1182
- if (!bindingId) {
1183
- throw new Error('pairing did not return agent_id');
1184
- }
1185
- const runtimeMeta = pairData.runtime_meta || resolved.runtimeMeta;
1186
- const binding = await ctx.upsertBinding({
1187
- id: bindingId,
1188
- adapter: 'ticlawk',
1189
- targetKey: bindingId,
1190
- targetMeta: {
1191
- agentId: bindingId,
1192
- runtime_host_id: hostId,
1193
- binding_key: pairData.binding_key || null,
1194
- },
1195
- runtime_host_id: hostId,
1196
- runtime_host_label: hostLabel,
1197
- runtime: resolved.runtime,
1198
- runtimeMeta,
1199
- displayName: resolved.displayName,
1200
- status: 'connected',
1201
- });
889
+ await reportHostCapabilitiesNow();
1202
890
  return {
1203
891
  statusCode: 200,
1204
892
  body: {
1205
893
  ok: true,
1206
- agentId: binding.id,
1207
- serviceType: resolved.runtime,
1208
- name: binding.displayName,
1209
- bindingKey: pairData.binding_key || null,
894
+ hostId,
895
+ hostLabel,
1210
896
  user: pairedIdentity,
1211
897
  },
1212
898
  };
1213
899
  }
1214
900
 
1215
- async function connectWithQrPairing(payload) {
1216
- const autoRuntime = Boolean(payload?.autoRuntime);
1217
- const runtimeOptions = autoRuntime ? await buildAutoRuntimeOptions(ctx, payload) : [];
1218
- if (autoRuntime) {
1219
- printDetectedRuntimeOptions(runtimeOptions);
1220
- if (runtimeOptions.length === 0) {
1221
- return connectError(400, 'No supported local agent harness detected in this terminal. Install or sign in to Codex, Claude Code, OpenCode, or pi, then run `ticlawk connect` again.');
1222
- }
1223
- }
1224
- const resolved = autoRuntime ? null : await ctx.resolveRuntimeBinding(payload);
1225
- const runtimeMeta = resolved?.runtimeMeta || {};
1226
- const workdir = getRuntimeWorkdir(runtimeMeta) || getRuntimeWorkdir(payload) || process.cwd();
901
+ async function connectWithQrPairing() {
1227
902
  const clientSecret = makeClientSecret();
1228
903
  const created = await api.createPairingSession({
1229
904
  client: 'ticlawk',
1230
905
  client_version: api.getTiclawkVersion(),
1231
906
  host_id: hostId,
1232
907
  host_label: hostLabel,
1233
- ...(autoRuntime ? { runtime_options: runtimeOptions } : { runtime: resolved.runtime }),
1234
- workdir,
1235
- display_name: resolved?.displayName || basename(workdir) || 'Agent',
908
+ scope: 'host',
1236
909
  challenge_hash: sha256Hex(clientSecret),
1237
910
  });
1238
911
  if (!created?.ok) {
@@ -1253,20 +926,9 @@ export function createTiclawkAdapter(ctx) {
1253
926
  pollAfterMs: Number(created.poll_after_ms || 1500),
1254
927
  expiresAt: created.expires_at,
1255
928
  });
1256
- const approvedRuntime = approved.runtime || approved.selected_runtime;
1257
- const approvedOption = runtimeOptions.find((option) => option.runtime === approvedRuntime) || null;
1258
- const finalResolved = resolved || {
1259
- runtime: approvedRuntime || approvedOption?.runtime,
1260
- displayName: approved.display_name || approvedOption?.display_name || runtimeLabel(approvedRuntime),
1261
- runtimeMeta: approved.runtime_meta || approvedOption?.runtime_meta || {},
1262
- };
1263
- if (!finalResolved.runtime) {
1264
- throw new Error('ticlawk pairing did not return a selected runtime');
1265
- }
1266
- return await finishPairing.call(this, finalResolved, {
929
+ return await finishHostPairing({
1267
930
  ...created,
1268
931
  ...approved,
1269
- binding_key: approved.binding_key || created.binding_key || null,
1270
932
  });
1271
933
  } catch (err) {
1272
934
  await api.cancelPairingSession({
@@ -1281,13 +943,18 @@ export function createTiclawkAdapter(ctx) {
1281
943
  id: 'ticlawk',
1282
944
 
1283
945
  async start() {
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.
1287
- await refreshBindings('startup');
1288
- await syncCredentials('startup');
1289
- await requestDrain('startup');
946
+ // No stale-delivery recovery anywhere if the daemon is killed
947
+ // mid-claim, the row stays `claimed` forever. lease_expires_at
948
+ // was dropped in X1; no cron has replaced it. Rare in practice;
949
+ // when it happens the fix is a one-row UPDATE in supabase.
950
+ startupSyncing = true;
1290
951
  connectWakeSocket();
952
+ try {
953
+ await refreshBindings('startup');
954
+ await requestDrain('startup');
955
+ } finally {
956
+ startupSyncing = false;
957
+ }
1291
958
  startAuditTimers();
1292
959
  },
1293
960
 
@@ -1324,131 +991,9 @@ export function createTiclawkAdapter(ctx) {
1324
991
  },
1325
992
 
1326
993
  async connect(payload) {
1327
- const config = loadPersistentConfig();
1328
- if (!payload?.__legacyConnect) {
1329
- try {
1330
- return await connectWithQrPairing.call(this, {
1331
- ...payload,
1332
- autoRuntime: true,
1333
- });
1334
- } catch (err) {
1335
- return connectError(err?.status || 500, err.message);
1336
- }
1337
- }
1338
-
1339
- // Archived setup-code connect path. It is intentionally not reachable
1340
- // from the public CLI; keep it here only until the old flow is deleted.
1341
- const connectCode = String(payload?.code || config.TICLAWK_SETUP_CODE || '').trim();
1342
- if (!connectCode) {
1343
- return connectError(400, 'legacy ticlawk setup-code connect requires code');
1344
- }
1345
-
1346
994
  try {
1347
- const resolved = await ctx.resolveRuntimeBinding(payload);
1348
- const runtimeMeta = resolved.runtimeMeta;
1349
- let currentIdentity = null;
1350
- const activeProfile = getActiveProfile();
1351
- if (activeProfile?.adapter === 'ticlawk') {
1352
- currentIdentity = readProfileMeta(activeProfile.adapter, activeProfile.userId) || {
1353
- userId: activeProfile.userId,
1354
- };
1355
- } else if (config[TICLAWK_CONNECTOR_API_KEY]) {
1356
- try {
1357
- const me = await api.getMe();
1358
- if (me?.userId || me?.user_id) {
1359
- currentIdentity = maskIdentity(me);
1360
- ensureLegacyProfile({
1361
- adapter: 'ticlawk',
1362
- userId: currentIdentity.userId,
1363
- meta: currentIdentity,
1364
- });
1365
- }
1366
- } catch {}
1367
- }
1368
-
1369
- let previewIdentity = null;
1370
- try {
1371
- const preview = await api.pairPreview({ code: connectCode });
1372
- if (preview?.ok) {
1373
- previewIdentity = maskIdentity(preview);
1374
- } else if (preview?.userId || preview?.user_id) {
1375
- previewIdentity = maskIdentity(preview);
1376
- }
1377
- } catch {}
1378
-
1379
- if (currentIdentity?.userId && !previewIdentity?.userId && !payload.switchUser) {
1380
- return {
1381
- statusCode: 409,
1382
- body: {
1383
- ok: false,
1384
- code: 'ticlawk_pairing_user_unverified',
1385
- currentUser: currentIdentity,
1386
- error: [
1387
- 'This ticlawk home is already paired to ticlawk user:',
1388
- ...formatIdentityLines(currentIdentity),
1389
- '',
1390
- 'Could not verify which ticlawk user owns the new pairing code.',
1391
- 'Refusing to switch users because existing agents may stop processing messages.',
1392
- '',
1393
- 'If you want to switch user, please rerun this command with --switch-user.',
1394
- ].join('\n'),
1395
- },
1396
- };
1397
- }
1398
-
1399
- if (
1400
- currentIdentity?.userId
1401
- && previewIdentity?.userId
1402
- && currentIdentity.userId !== previewIdentity.userId
1403
- && !payload.switchUser
1404
- ) {
1405
- let affectedAgents = [];
1406
- try {
1407
- affectedAgents = await api.getAgents({ hostId });
1408
- } catch {}
1409
- const message = [
1410
- 'This ticlawk home is already paired to ticlawk user:',
1411
- ...formatIdentityLines(currentIdentity),
1412
- '',
1413
- 'The pairing code belongs to a different ticlawk user:',
1414
- ...formatIdentityLines(previewIdentity),
1415
- '',
1416
- 'Refusing to switch users because these agents are currently bound to this host:',
1417
- ...formatAffectedAgents(affectedAgents),
1418
- '',
1419
- 'Switching would stop this daemon from processing messages for those agents.',
1420
- '',
1421
- 'If you want to switch user, please rerun this command with --switch-user.',
1422
- ].join('\n');
1423
- return {
1424
- statusCode: 409,
1425
- body: {
1426
- ok: false,
1427
- error: message,
1428
- code: 'ticlawk_user_mismatch',
1429
- currentUser: currentIdentity,
1430
- pairingUser: previewIdentity,
1431
- affectedAgents,
1432
- },
1433
- };
1434
- }
1435
-
1436
- const pairData = await api.pair({
1437
- code: connectCode,
1438
- name: resolved.displayName,
1439
- serviceType: resolved.runtime,
1440
- runtimeMeta,
1441
- runtime_host_id: hostId,
1442
- runtime_host_label: hostLabel,
1443
- });
1444
- if (!pairData?.ok) {
1445
- return { statusCode: pairData?.statusCode || 401, body: pairData };
1446
- }
1447
- return finishPairing.call(this, resolved, {
1448
- ...pairData,
1449
- agent_id: pairData.agentId,
1450
- connector_api_key: pairData.apiKey,
1451
- });
995
+ void payload;
996
+ return await connectWithQrPairing();
1452
997
  } catch (err) {
1453
998
  return connectError(err?.status || 500, err.message);
1454
999
  }
@@ -1512,52 +1057,3 @@ export function createTiclawkAdapter(ctx) {
1512
1057
  },
1513
1058
  };
1514
1059
  }
1515
-
1516
- export function getTiclawkAuthHelp() {
1517
- return `ticlawk auth ticlawk --code <6-digit-code> [--api-url <url>]
1518
-
1519
- Options:
1520
- --code <code> 6-digit setup code from the ticlawk app
1521
- --api-url <url> optional ticlawk API base URL override
1522
- `;
1523
- }
1524
-
1525
- export async function runTiclawkAuth(rawArgs) {
1526
- const args = parseOptionArgs(rawArgs);
1527
- if (args.help || args.h) {
1528
- return {
1529
- statusCode: 200,
1530
- body: {
1531
- ok: true,
1532
- help: getTiclawkAuthHelp(),
1533
- },
1534
- };
1535
- }
1536
- const code = String(args.code || '').trim();
1537
- if (!code) {
1538
- return {
1539
- statusCode: 400,
1540
- body: {
1541
- ok: false,
1542
- error: 'ticlawk auth requires --code',
1543
- },
1544
- };
1545
- }
1546
- const updates = {
1547
- TICLAWK_SETUP_CODE: code,
1548
- };
1549
- const apiUrl = String(args['api-url'] || '').trim();
1550
- if (apiUrl) {
1551
- updates.TICLAWK_API_URL = apiUrl;
1552
- }
1553
- persistConfig(updates);
1554
- return {
1555
- statusCode: 200,
1556
- body: {
1557
- ok: true,
1558
- adapter: 'ticlawk',
1559
- setupCode: 'set',
1560
- apiUrl: apiUrl || undefined,
1561
- },
1562
- };
1563
- }