ticlawk 0.1.16-dev.14 → 0.1.16-dev.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.
@@ -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,145 +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
- // Charter is the workstream's CoS-authored markdown spec. It's a stable
144
- // prefix-cacheable block — same bytes across every turn in the same
145
- // conversation — so it sits above the per-turn envelope.
146
- function buildCharterBlock(msg) {
147
- const charter = (msg.conversation_charter || '').trim();
148
- if (!charter) return '';
149
- return ['[charter]', charter, '[/charter]'].join('\n');
150
- }
151
-
152
- // CoS-only addendum. Splice above the per-turn envelope (after charter,
153
- // before group context) when the recipient is the user's CoS.
154
- // Same bytes every turn → still prefix-cache-friendly.
155
- const COS_ADDENDUM = [
156
- '[cos-role]',
157
- 'You are the user\'s Chief of Staff (CoS). You answer to the user. In every workstream:',
158
- '',
159
- '1. Push — break goals into agent-executable tasks, supervise progress, do not let work stall in place.',
160
- '2. Simplify — agents should work simply and reliably. Stop over-engineering, junk-code piles, and detours.',
161
- '3. Track — keep an entry per workstream in MEMORY.md; update it whenever state changes.',
162
- '4. Represent — bundle resource / decision / approval requests and bring them to the user; do not flood the user with raw asks.',
163
- '',
164
- 'Wake-on-event: every turn (including ambient delivery) you must read the message, update MEMORY.md if anything changed, and only reply when you need to intervene. Otherwise stop silently.',
165
- '',
166
- 'Posture: bring a proposal, default to acting on it, change course if the user objects. "I propose A — reasons 1/2/3. Unless you redirect within 24h, I\'ll proceed with A." Not "should we do A or B?"',
167
- '',
168
- 'Owner model: hold the user\'s preferences and constraints across workstreams. Apply them consistently. Do not "for-your-own-good" around stated preferences.',
169
- '',
170
- 'Briefing posture: publish briefings via `ticlawk briefing publish` — this is a SEPARATE surface from chat. Two content formats: `--text "<≤100 chars>"` for short pings, or `--html <path/to/file.html>` for rich one-page bodies. Briefings do NOT appear in the chat stream — they only render in Office → Briefings as full-screen cards owner steps through. Do not over-spam — one per meaningful state change. When the owner taps "comment" on a briefing, the inbound reply message carries metadata.context_ref = { kind: "briefing", briefing_id }; thread the conversation from there.',
171
- '',
172
- 'Dashboard posture: maintain a per-workstream dashboard via `ticlawk dashboard set --target "#<ws>"` (stdin JSON: { data_json, html_template }). data_json holds the live numbers; html_template is the hand-written rendering. CoS replaces wholesale — there is no partial update protocol, and there is no schema lock-in on data_json. Use the dashboard for at-a-glance state; use Briefings for narrative. When the owner replies to a dashboard via the Office "聊一下" path, the inbound message carries metadata.context_ref pointing at the workstream; thread the conversation from there.',
173
- '',
174
- 'Cadence: schedule your own daily morning briefing via `ticlawk reminder schedule --title "morning briefing" --in-minutes 1440 --anchor-conversation-id <your DM with owner>` after you handle this turn. The reminder fires by waking you with a system message; treat that wake as the trigger to compile the day\'s briefings across workstreams. Re-schedule on each fire so the cadence persists. Skip a day only if you are explicitly told to.',
175
- '[/cos-role]',
176
- ].join('\n');
177
-
178
- function buildCosAddendum(msg) {
179
- if (!msg.recipient_is_cos) return '';
180
- return COS_ADDENDUM;
181
- }
182
-
183
- // Wrap each per-turn message with an explicit reply instruction so the
184
- // runtime LLM never has to remember the standing prompt to figure out
185
- // HOW to reply. Codex in particular treats the developerInstructions as
186
- // background and ignores the chat-send pattern without this per-turn
187
- // nudge.
188
- function buildWakePromptText({ envelopeHeader, target, rawText, groupContext, charterBlock, cosAddendum }) {
189
- const body = `${envelopeHeader} ${rawText || ''}`.trim();
190
- const lines = [];
191
- if (charterBlock) {
192
- lines.push(charterBlock, '');
193
- }
194
- if (cosAddendum) {
195
- lines.push(cosAddendum, '');
196
- }
197
- lines.push('New message received:', '', body);
198
- if (groupContext) {
199
- lines.push('', groupContext);
200
- }
201
- lines.push(
202
- '',
203
- `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.`,
204
- '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.',
205
- '',
206
- '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.',
207
- );
208
- return lines.join('\n');
209
- }
210
-
211
74
  export function normalizeInboundMessage(msg) {
212
75
  // Claimed delivery rows carry the recipient agent id; legacy messages
213
76
  // (history sync, manual inserts) may still use plain agent_id. Prefer
@@ -216,23 +79,8 @@ export function normalizeInboundMessage(msg) {
216
79
  const messageId = msg.id || msg.message_id || null;
217
80
  const deliveryId = msg.delivery_id || null;
218
81
  const media = normalizeInboundMediaAssets(msg);
219
- const rawText = msg.text || '';
220
82
  const enriched = { ...msg, id: messageId };
221
- const baseHeader = buildEnvelopeHeader(enriched);
222
- // Task + reactions suffixes are appended INSIDE the envelope so an
223
- // agent can see at a glance whether a message is already claimed and
224
- // who has already acknowledged it: `[task #N status=… assignee=…]` +
225
- // `[reactions: …]`.
226
- const taskSuffix = buildTaskSuffix(enriched);
227
- const reactionsSuffix = buildReactionsSuffix(enriched);
228
- const header = baseHeader + taskSuffix + reactionsSuffix;
229
- const target = buildEnvelopeTarget(enriched);
230
- const groupContext = buildGroupContextBlock(enriched);
231
- const charterBlock = buildCharterBlock(enriched);
232
- const cosAddendum = buildCosAddendum(enriched);
233
- const text = header
234
- ? buildWakePromptText({ envelopeHeader: header, target, rawText, groupContext, charterBlock, cosAddendum })
235
- : rawText;
83
+ const wakePrompt = buildInboundWakePrompt(enriched);
236
84
  return {
237
85
  bindingId: recipientAgentId,
238
86
  messageId,
@@ -240,10 +88,10 @@ export function normalizeInboundMessage(msg) {
240
88
  conversationId: msg.conversation_id || null,
241
89
  seq: msg.seq != null ? Number(msg.seq) : null,
242
90
  senderType: msg.sender_type || 'human',
243
- envelopeHeader: header,
244
- envelopeTarget: target,
245
- text,
246
- rawText,
91
+ envelopeHeader: wakePrompt.header,
92
+ envelopeTarget: wakePrompt.target,
93
+ text: wakePrompt.text,
94
+ rawText: wakePrompt.rawText,
247
95
  action: msg.action || (media.length > 0 ? 'image' : 'task'),
248
96
  media,
249
97
  raw: {
@@ -292,6 +140,7 @@ const RUNTIME_META_KEYS = [
292
140
  'runtimePath',
293
141
  'rotatePending',
294
142
  'lastRotatedAt',
143
+ 'conversationSessions',
295
144
  // claude_code
296
145
  'claudePath',
297
146
  'claudeVersion',
@@ -408,6 +257,31 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
408
257
  return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
409
258
  }
410
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
+
411
285
  function runtimeLabel(runtime) {
412
286
  if (runtime === 'claude_code') return 'Claude Code';
413
287
  if (runtime === 'opencode') return 'OpenCode';
@@ -521,8 +395,10 @@ export function createTiclawkAdapter(ctx) {
521
395
  let bindingAuditTimer = null;
522
396
  let jobsWakeTimer = null;
523
397
  let bindingsWakeTimer = null;
398
+ let credentialsWakeTimer = null;
524
399
  let lastJobsWakeAt = 0;
525
400
  let lastBindingsWakeAt = 0;
401
+ let lastCredentialsWakeAt = 0;
526
402
  let updateRequired = null;
527
403
  let lastUpdateRequiredLogAt = 0;
528
404
 
@@ -698,7 +574,7 @@ export function createTiclawkAdapter(ctx) {
698
574
 
699
575
  const binding = await ctx.persistBinding(buildBindingFromSource(msg));
700
576
  if (!binding?.runtime) {
701
- throw new Error('claimed message missing runtime binding');
577
+ throw new Error('claimed delivery missing runtime binding');
702
578
  }
703
579
  if (!belongsToRuntimeHost(binding, hostId)) {
704
580
  await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
@@ -839,6 +715,33 @@ export function createTiclawkAdapter(ctx) {
839
715
  return hydrated;
840
716
  }
841
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
+
842
745
  async function releaseBlockedRows(agentId, messages, reason) {
843
746
  for (const msg of messages) {
844
747
  if (!msg?.delivery_id) continue;
@@ -887,7 +790,8 @@ export function createTiclawkAdapter(ctx) {
887
790
  return { failed: true, claimed: 0, launched: 0 };
888
791
  }
889
792
 
890
- const claimed = Array.isArray(data) ? data.length : 0;
793
+ const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
794
+ const claimed = orderedData.length;
891
795
  debugLog('ticlawk', 'claim.result', {
892
796
  reason,
893
797
  hostId,
@@ -906,12 +810,12 @@ export function createTiclawkAdapter(ctx) {
906
810
  });
907
811
  }
908
812
 
909
- if (!Array.isArray(data) || data.length === 0) {
813
+ if (orderedData.length === 0) {
910
814
  return { failed: false, claimed: 0, launched: 0 };
911
815
  }
912
816
 
913
817
  const grouped = new Map();
914
- for (const msg of data) {
818
+ for (const msg of orderedData) {
915
819
  const agentId = getAgentIdFromPayload(msg);
916
820
  if (!agentId) {
917
821
  // Claim rows must carry the recipient agent id; a missing value
@@ -950,7 +854,7 @@ export function createTiclawkAdapter(ctx) {
950
854
  launched += messages.length;
951
855
  }
952
856
 
953
- return { failed: false, claimed: data.length, launched };
857
+ return { failed: false, claimed: orderedData.length, launched };
954
858
  }
955
859
 
956
860
  async function runDrain(reason) {
@@ -989,6 +893,15 @@ export function createTiclawkAdapter(ctx) {
989
893
  bindingsWakeTimer.unref?.();
990
894
  }
991
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
+
992
905
  async function reportHostCapabilitiesNow() {
993
906
  const entries = await Promise.all(Object.entries(ctx.runtimes || {})
994
907
  .filter(([, runtime]) => typeof runtime?.health === 'function')
@@ -1028,6 +941,7 @@ export function createTiclawkAdapter(ctx) {
1028
941
  void refreshBindings('wake.hello')
1029
942
  .then(() => requestDrain('wake.hello'))
1030
943
  .catch(() => {});
944
+ void syncCredentials('wake.hello');
1031
945
  void reportHostCapabilitiesNow();
1032
946
  return;
1033
947
  }
@@ -1049,6 +963,17 @@ export function createTiclawkAdapter(ctx) {
1049
963
  scheduleRefreshAndDrain('wake.bindings.changed');
1050
964
  return;
1051
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
+ }
1052
977
  if (event?.type === 'auth.revoked') {
1053
978
  wakeState.lastError = 'auth revoked';
1054
979
  debugError('ticlawk-wake', 'auth.revoked', {});
@@ -1248,6 +1173,7 @@ export function createTiclawkAdapter(ctx) {
1248
1173
  // was dropped in X1; no cron has replaced it. Rare in practice;
1249
1174
  // when it happens the fix is a one-row UPDATE in supabase.
1250
1175
  await refreshBindings('startup');
1176
+ await syncCredentials('startup');
1251
1177
  await requestDrain('startup');
1252
1178
  connectWakeSocket();
1253
1179
  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
  }