ticlawk 0.1.16-dev.8 → 0.1.16

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 (41) hide show
  1. package/README.md +15 -3
  2. package/bin/ticlawk.mjs +208 -26
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +283 -48
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +126 -121
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +560 -36
  9. package/src/core/agent-cli-handlers.mjs +435 -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 +119 -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 +108 -77
  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 +55 -0
  23. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  24. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +46 -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 +134 -275
  31. package/src/runtimes/_shared/wake-prompt.mjs +261 -0
  32. package/src/runtimes/claude-code/index.mjs +19 -46
  33. package/src/runtimes/claude-code/session.mjs +2 -7
  34. package/src/runtimes/codex/index.mjs +115 -63
  35. package/src/runtimes/codex/session.mjs +2 -12
  36. package/src/runtimes/openclaw/index.mjs +11 -24
  37. package/src/runtimes/opencode/index.mjs +38 -60
  38. package/src/runtimes/opencode/session.mjs +12 -12
  39. package/src/runtimes/pi/index.mjs +38 -60
  40. package/src/runtimes/pi/session.mjs +9 -6
  41. package/ticlawk.mjs +0 -30
@@ -10,13 +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 { 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';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
  const qrcode = require('qrcode-terminal');
18
19
  const JOBS_WAKE_DEBOUNCE_MS = 100;
19
20
  const BINDINGS_WAKE_DEBOUNCE_MS = 500;
21
+ const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
20
22
  const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
21
23
  const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
22
24
  const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
@@ -69,98 +71,6 @@ function normalizeInboundMediaAssets(msg) {
69
71
  .filter(Boolean);
70
72
  }
71
73
 
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
74
  export function normalizeInboundMessage(msg) {
165
75
  // Claimed delivery rows carry the recipient agent id; legacy messages
166
76
  // (history sync, manual inserts) may still use plain agent_id. Prefer
@@ -169,21 +79,8 @@ export function normalizeInboundMessage(msg) {
169
79
  const messageId = msg.id || msg.message_id || null;
170
80
  const deliveryId = msg.delivery_id || null;
171
81
  const media = normalizeInboundMediaAssets(msg);
172
- const rawText = msg.text || '';
173
82
  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;
83
+ const wakePrompt = buildInboundWakePrompt(enriched);
187
84
  return {
188
85
  bindingId: recipientAgentId,
189
86
  messageId,
@@ -191,10 +88,10 @@ export function normalizeInboundMessage(msg) {
191
88
  conversationId: msg.conversation_id || null,
192
89
  seq: msg.seq != null ? Number(msg.seq) : null,
193
90
  senderType: msg.sender_type || 'human',
194
- envelopeHeader: header,
195
- envelopeTarget: target,
196
- text,
197
- rawText,
91
+ envelopeHeader: wakePrompt.header,
92
+ envelopeTarget: wakePrompt.target,
93
+ text: wakePrompt.text,
94
+ rawText: wakePrompt.rawText,
198
95
  action: msg.action || (media.length > 0 ? 'image' : 'task'),
199
96
  media,
200
97
  raw: {
@@ -243,6 +140,7 @@ const RUNTIME_META_KEYS = [
243
140
  'runtimePath',
244
141
  'rotatePending',
245
142
  'lastRotatedAt',
143
+ 'conversationSessions',
246
144
  // claude_code
247
145
  'claudePath',
248
146
  'claudeVersion',
@@ -359,6 +257,31 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
359
257
  return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
360
258
  }
361
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
+
362
285
  function runtimeLabel(runtime) {
363
286
  if (runtime === 'claude_code') return 'Claude Code';
364
287
  if (runtime === 'opencode') return 'OpenCode';
@@ -472,8 +395,10 @@ export function createTiclawkAdapter(ctx) {
472
395
  let bindingAuditTimer = null;
473
396
  let jobsWakeTimer = null;
474
397
  let bindingsWakeTimer = null;
398
+ let credentialsWakeTimer = null;
475
399
  let lastJobsWakeAt = 0;
476
400
  let lastBindingsWakeAt = 0;
401
+ let lastCredentialsWakeAt = 0;
477
402
  let updateRequired = null;
478
403
  let lastUpdateRequiredLogAt = 0;
479
404
 
@@ -649,7 +574,7 @@ export function createTiclawkAdapter(ctx) {
649
574
 
650
575
  const binding = await ctx.persistBinding(buildBindingFromSource(msg));
651
576
  if (!binding?.runtime) {
652
- throw new Error('claimed message missing runtime binding');
577
+ throw new Error('claimed delivery missing runtime binding');
653
578
  }
654
579
  if (!belongsToRuntimeHost(binding, hostId)) {
655
580
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -755,6 +680,27 @@ export function createTiclawkAdapter(ctx) {
755
680
  error: err?.message || 'unknown error',
756
681
  });
757
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
+ }
758
704
  }
759
705
  const pruned = await pruneDeletedBindings(channels, reason);
760
706
  debugLog('ticlawk', 'binding.refresh-ok', {
@@ -769,6 +715,33 @@ export function createTiclawkAdapter(ctx) {
769
715
  return hydrated;
770
716
  }
771
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
+
772
745
  async function releaseBlockedRows(agentId, messages, reason) {
773
746
  for (const msg of messages) {
774
747
  if (!msg?.delivery_id) continue;
@@ -817,7 +790,8 @@ export function createTiclawkAdapter(ctx) {
817
790
  return { failed: true, claimed: 0, launched: 0 };
818
791
  }
819
792
 
820
- const claimed = Array.isArray(data) ? data.length : 0;
793
+ const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
794
+ const claimed = orderedData.length;
821
795
  debugLog('ticlawk', 'claim.result', {
822
796
  reason,
823
797
  hostId,
@@ -836,12 +810,12 @@ export function createTiclawkAdapter(ctx) {
836
810
  });
837
811
  }
838
812
 
839
- if (!Array.isArray(data) || data.length === 0) {
813
+ if (orderedData.length === 0) {
840
814
  return { failed: false, claimed: 0, launched: 0 };
841
815
  }
842
816
 
843
817
  const grouped = new Map();
844
- for (const msg of data) {
818
+ for (const msg of orderedData) {
845
819
  const agentId = getAgentIdFromPayload(msg);
846
820
  if (!agentId) {
847
821
  // Claim rows must carry the recipient agent id; a missing value
@@ -880,7 +854,7 @@ export function createTiclawkAdapter(ctx) {
880
854
  launched += messages.length;
881
855
  }
882
856
 
883
- return { failed: false, claimed: data.length, launched };
857
+ return { failed: false, claimed: orderedData.length, launched };
884
858
  }
885
859
 
886
860
  async function runDrain(reason) {
@@ -919,6 +893,15 @@ export function createTiclawkAdapter(ctx) {
919
893
  bindingsWakeTimer.unref?.();
920
894
  }
921
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
+
922
905
  async function reportHostCapabilitiesNow() {
923
906
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
924
907
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -958,6 +941,7 @@ export function createTiclawkAdapter(ctx) {
958
941
  void refreshBindings('wake.hello')
959
942
  .then(() => requestDrain('wake.hello'))
960
943
  .catch(() => {});
944
+ void syncCredentials('wake.hello');
961
945
  void reportHostCapabilitiesNow();
962
946
  return;
963
947
  }
@@ -979,6 +963,17 @@ export function createTiclawkAdapter(ctx) {
979
963
  scheduleRefreshAndDrain('wake.bindings.changed');
980
964
  return;
981
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
+ }
982
977
  if (event?.type === 'auth.revoked') {
983
978
  wakeState.lastError = 'auth revoked';
984
979
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1013,7 +1008,17 @@ export function createTiclawkAdapter(ctx) {
1013
1008
 
1014
1009
  function connectWakeSocket() {
1015
1010
  connectorSocket = new TiclawkWakeClient({
1016
- 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
+ },
1017
1022
  getApiKey: api.getApiKey,
1018
1023
  onEvent: handleWakeEvent,
1019
1024
  onStatus: handleWakeStatus,
@@ -1163,11 +1168,11 @@ export function createTiclawkAdapter(ctx) {
1163
1168
  id: 'ticlawk',
1164
1169
 
1165
1170
  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.
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.
1170
1174
  await refreshBindings('startup');
1175
+ await syncCredentials('startup');
1171
1176
  await requestDrain('startup');
1172
1177
  connectWakeSocket();
1173
1178
  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
  }