ticlawk 0.1.17-dev.17 → 0.1.17-dev.19

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 (33) hide show
  1. package/README.md +3 -17
  2. package/bin/ticlawk.mjs +21 -245
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +51 -327
  5. package/src/adapters/ticlawk/credentials.mjs +1 -41
  6. package/src/adapters/ticlawk/index.mjs +27 -249
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +22 -703
  9. package/src/core/agent-cli-handlers.mjs +18 -519
  10. package/src/core/agent-home.mjs +1 -64
  11. package/src/core/events/worker-events.mjs +36 -32
  12. package/src/core/http.mjs +0 -138
  13. package/src/core/runtime-contract.mjs +1 -0
  14. package/src/core/runtime-env.mjs +0 -7
  15. package/src/core/runtime-support.mjs +78 -130
  16. package/src/runtimes/_shared/incoming-message-prompt.mjs +232 -0
  17. package/src/runtimes/_shared/runtime-base-instructions.mjs +34 -0
  18. package/src/runtimes/claude-code/index.mjs +48 -21
  19. package/src/runtimes/claude-code/session.mjs +7 -2
  20. package/src/runtimes/codex/index.mjs +64 -116
  21. package/src/runtimes/codex/session.mjs +12 -2
  22. package/src/runtimes/openclaw/index.mjs +30 -17
  23. package/src/runtimes/opencode/index.mjs +64 -42
  24. package/src/runtimes/opencode/session.mjs +14 -14
  25. package/src/runtimes/pi/index.mjs +64 -42
  26. package/src/runtimes/pi/session.mjs +8 -11
  27. package/ticlawk.mjs +30 -0
  28. package/src/runtimes/_shared/agent-handbook.mjs +0 -38
  29. package/src/runtimes/_shared/brand.mjs +0 -2
  30. package/src/runtimes/_shared/goal-step-prompt.mjs +0 -133
  31. package/src/runtimes/_shared/goal-task-protocol.mjs +0 -50
  32. package/src/runtimes/_shared/standing-prompt.mjs +0 -331
  33. package/src/runtimes/_shared/wake-prompt.mjs +0 -296
@@ -9,17 +9,15 @@ 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 { buildIncomingMessagePrompt } from '../../runtimes/_shared/incoming-message-prompt.mjs';
12
13
  import * as api from './api.mjs';
13
- import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
14
+ import { persistApiCredential } from './credentials.mjs';
14
15
  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
16
 
18
17
  const require = createRequire(import.meta.url);
19
18
  const qrcode = require('qrcode-terminal');
20
19
  const JOBS_WAKE_DEBOUNCE_MS = 100;
21
20
  const BINDINGS_WAKE_DEBOUNCE_MS = 500;
22
- const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
23
21
  const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
24
22
  const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
25
23
  const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
@@ -80,28 +78,24 @@ export function normalizeInboundMessage(msg) {
80
78
  const messageId = msg.id || msg.message_id || null;
81
79
  const deliveryId = msg.delivery_id || null;
82
80
  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);
81
+ const prompt = buildIncomingMessagePrompt({ ...msg, id: messageId });
88
82
  return {
89
83
  bindingId: recipientAgentId,
90
84
  messageId,
91
85
  deliveryId,
92
86
  conversationId: msg.conversation_id || null,
93
- lane: isTransition ? 'goal' : 'chat',
94
87
  seq: msg.seq != null ? Number(msg.seq) : null,
95
88
  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')),
89
+ envelopeHeader: prompt.header,
90
+ envelopeTarget: prompt.target,
91
+ text: prompt.text,
92
+ rawText: prompt.rawText,
93
+ action: msg.action || (media.length > 0 ? 'image' : 'task'),
101
94
  media,
102
95
  raw: {
103
96
  ...msg,
104
97
  id: messageId,
98
+ type: prompt.type,
105
99
  },
106
100
  };
107
101
  }
@@ -135,19 +129,6 @@ function getAgentIdFromPayload(payload) {
135
129
  return String(payload?.recipient_agent_id || '').trim();
136
130
  }
137
131
 
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
132
  // Whitelist of meta keys runtimes actually consume. Anything else in
152
133
  // the source row's meta blob is dropped on the floor so stale fields
153
134
  // (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
@@ -158,11 +139,6 @@ const RUNTIME_META_KEYS = [
158
139
  'runtimePath',
159
140
  'rotatePending',
160
141
  '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
142
  // claude_code
167
143
  'claudePath',
168
144
  'claudeVersion',
@@ -279,31 +255,6 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
279
255
  return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
280
256
  }
281
257
 
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
258
  function runtimeLabel(runtime) {
308
259
  if (runtime === 'claude_code') return 'Claude Code';
309
260
  if (runtime === 'opencode') return 'OpenCode';
@@ -417,10 +368,8 @@ export function createTiclawkAdapter(ctx) {
417
368
  let bindingAuditTimer = null;
418
369
  let jobsWakeTimer = null;
419
370
  let bindingsWakeTimer = null;
420
- let credentialsWakeTimer = null;
421
371
  let lastJobsWakeAt = 0;
422
372
  let lastBindingsWakeAt = 0;
423
- let lastCredentialsWakeAt = 0;
424
373
  let updateRequired = null;
425
374
  let lastUpdateRequiredLogAt = 0;
426
375
 
@@ -596,7 +545,7 @@ export function createTiclawkAdapter(ctx) {
596
545
 
597
546
  const binding = await ctx.persistBinding(buildBindingFromSource(msg));
598
547
  if (!binding?.runtime) {
599
- throw new Error('claimed delivery missing runtime binding');
548
+ throw new Error('claimed message missing runtime binding');
600
549
  }
601
550
  if (!belongsToRuntimeHost(binding, hostId)) {
602
551
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -664,91 +613,6 @@ export function createTiclawkAdapter(ctx) {
664
613
  }
665
614
  }
666
615
 
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
616
  async function refreshBindings(reason = 'manual') {
753
617
  let channels = [];
754
618
  const startedAt = Date.now();
@@ -787,27 +651,6 @@ export function createTiclawkAdapter(ctx) {
787
651
  error: err?.message || 'unknown error',
788
652
  });
789
653
  }
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
654
  }
812
655
  const pruned = await pruneDeletedBindings(channels, reason);
813
656
  debugLog('ticlawk', 'binding.refresh-ok', {
@@ -822,33 +665,6 @@ export function createTiclawkAdapter(ctx) {
822
665
  return hydrated;
823
666
  }
824
667
 
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
668
  async function releaseBlockedRows(agentId, messages, reason) {
853
669
  for (const msg of messages) {
854
670
  if (!msg?.delivery_id) continue;
@@ -897,8 +713,7 @@ export function createTiclawkAdapter(ctx) {
897
713
  return { failed: true, claimed: 0, launched: 0 };
898
714
  }
899
715
 
900
- const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
901
- const claimed = orderedData.length;
716
+ const claimed = Array.isArray(data) ? data.length : 0;
902
717
  debugLog('ticlawk', 'claim.result', {
903
718
  reason,
904
719
  hostId,
@@ -917,12 +732,12 @@ export function createTiclawkAdapter(ctx) {
917
732
  });
918
733
  }
919
734
 
920
- if (orderedData.length === 0) {
735
+ if (!Array.isArray(data) || data.length === 0) {
921
736
  return { failed: false, claimed: 0, launched: 0 };
922
737
  }
923
738
 
924
739
  const grouped = new Map();
925
- for (const msg of orderedData) {
740
+ for (const msg of data) {
926
741
  const agentId = getAgentIdFromPayload(msg);
927
742
  if (!agentId) {
928
743
  // Claim rows must carry the recipient agent id; a missing value
@@ -935,39 +750,33 @@ export function createTiclawkAdapter(ctx) {
935
750
  });
936
751
  continue;
937
752
  }
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);
753
+ const bucket = grouped.get(agentId) || [];
754
+ bucket.push(msg);
755
+ grouped.set(agentId, bucket);
943
756
  }
944
757
 
945
758
  let launched = 0;
946
- for (const [channelKey, { agentId, lane, messages }] of grouped.entries()) {
947
- if (processingChannels.has(channelKey)) {
759
+ for (const [agentId, messages] of grouped.entries()) {
760
+ if (processingChannels.has(agentId)) {
948
761
  debugError('ticlawk', 'claim.blocked-claimed-rows', {
949
762
  reason,
950
- channelKey,
951
763
  agentId,
952
- lane,
953
764
  blockedRows: messages.length,
954
765
  });
955
766
  await releaseBlockedRows(agentId, messages, reason);
956
767
  continue;
957
768
  }
958
- const run = (lane === 'goal'
959
- ? processGoalTransitionsForAgent(agentId, messages)
960
- : processPendingMessagesForAgent(agentId, messages))
769
+ const run = processPendingMessagesForAgent(agentId, messages)
961
770
  .catch(() => {})
962
771
  .finally(() => {
963
- processingChannels.delete(channelKey);
772
+ processingChannels.delete(agentId);
964
773
  void requestDrain('channel.completed');
965
774
  });
966
- processingChannels.set(channelKey, run);
775
+ processingChannels.set(agentId, run);
967
776
  launched += messages.length;
968
777
  }
969
778
 
970
- return { failed: false, claimed: orderedData.length, launched };
779
+ return { failed: false, claimed: data.length, launched };
971
780
  }
972
781
 
973
782
  async function runDrain(reason) {
@@ -1006,15 +815,6 @@ export function createTiclawkAdapter(ctx) {
1006
815
  bindingsWakeTimer.unref?.();
1007
816
  }
1008
817
 
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
818
  async function reportHostCapabilitiesNow() {
1019
819
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
1020
820
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -1054,7 +854,6 @@ export function createTiclawkAdapter(ctx) {
1054
854
  void refreshBindings('wake.hello')
1055
855
  .then(() => requestDrain('wake.hello'))
1056
856
  .catch(() => {});
1057
- void syncCredentials('wake.hello');
1058
857
  void reportHostCapabilitiesNow();
1059
858
  return;
1060
859
  }
@@ -1076,17 +875,6 @@ export function createTiclawkAdapter(ctx) {
1076
875
  scheduleRefreshAndDrain('wake.bindings.changed');
1077
876
  return;
1078
877
  }
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
878
  if (event?.type === 'auth.revoked') {
1091
879
  wakeState.lastError = 'auth revoked';
1092
880
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1121,17 +909,7 @@ export function createTiclawkAdapter(ctx) {
1121
909
 
1122
910
  function connectWakeSocket() {
1123
911
  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
- },
912
+ getUrl: api.getConnectorWsUrl,
1135
913
  getApiKey: api.getApiKey,
1136
914
  onEvent: handleWakeEvent,
1137
915
  onStatus: handleWakeStatus,
@@ -1281,11 +1059,11 @@ export function createTiclawkAdapter(ctx) {
1281
1059
  id: 'ticlawk',
1282
1060
 
1283
1061
  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.
1062
+ // No stale-delivery recovery anywhere if the daemon is killed
1063
+ // mid-claim, the row stays `claimed` forever. lease_expires_at
1064
+ // was dropped in X1; no cron has replaced it. Rare in practice;
1065
+ // when it happens the fix is a one-row UPDATE in supabase.
1287
1066
  await refreshBindings('startup');
1288
- await syncCredentials('startup');
1289
1067
  await requestDrain('startup');
1290
1068
  connectWakeSocket();
1291
1069
  startAuditTimers();
@@ -165,7 +165,7 @@ export class TiclawkWakeClient {
165
165
  return;
166
166
  }
167
167
 
168
- if (type === 'jobs.available' || type === 'bindings.changed' || type === 'credentials.changed') {
168
+ if (type === 'jobs.available' || type === 'bindings.changed') {
169
169
  this.onEvent?.(msg);
170
170
  return;
171
171
  }