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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -3
  2. package/bin/ticlawk.mjs +255 -21
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +350 -50
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +248 -130
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +715 -18
  9. package/src/core/agent-cli-handlers.mjs +556 -18
  10. package/src/core/agent-home.mjs +81 -1
  11. package/src/core/argv.mjs +11 -1
  12. package/src/core/events/worker-events.mjs +32 -36
  13. package/src/core/http.mjs +152 -0
  14. package/src/core/runtime-contract.mjs +0 -1
  15. package/src/core/runtime-env.mjs +7 -0
  16. package/src/core/runtime-support.mjs +130 -78
  17. package/src/runtimes/_shared/agent-handbook.mjs +45 -0
  18. package/src/runtimes/_shared/brand.mjs +2 -0
  19. package/src/runtimes/_shared/goal-step-prompt.mjs +98 -0
  20. package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
  21. package/src/runtimes/_shared/handbook/BASICS.md +27 -0
  22. package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
  23. package/src/runtimes/_shared/handbook/COMMUNICATION.md +55 -0
  24. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  25. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +47 -0
  26. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
  27. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
  28. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
  29. package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
  30. package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
  31. package/src/runtimes/_shared/standing-prompt.mjs +124 -279
  32. package/src/runtimes/_shared/wake-prompt.mjs +268 -0
  33. package/src/runtimes/claude-code/index.mjs +19 -46
  34. package/src/runtimes/claude-code/session.mjs +2 -7
  35. package/src/runtimes/codex/index.mjs +115 -63
  36. package/src/runtimes/codex/session.mjs +2 -12
  37. package/src/runtimes/openclaw/index.mjs +11 -24
  38. package/src/runtimes/opencode/index.mjs +38 -60
  39. package/src/runtimes/opencode/session.mjs +12 -12
  40. package/src/runtimes/pi/index.mjs +38 -60
  41. package/src/runtimes/pi/session.mjs +9 -6
  42. package/ticlawk.mjs +0 -30
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Per-turn inbound wake prompt builder.
3
+ *
4
+ * Standing prompts define durable runtime behavior. This module owns the
5
+ * dynamic wrapper around each delivered Ticlawk message: who sent it, why it
6
+ * reached this agent, the exact reply target, and any attached goal/task/quote
7
+ * context for this turn.
8
+ */
9
+
10
+ function promptBlock(text) {
11
+ return text.trim();
12
+ }
13
+
14
+ export function buildEnvelopeTarget(msg) {
15
+ const convType = msg.conversation_type || 'dm';
16
+ const conversationId = msg.conversation_id || '';
17
+ const senderHandle = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
18
+ if (convType === 'dm') {
19
+ return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
20
+ }
21
+ if (convType === 'thread') {
22
+ const groupName = msg.conversation_name || conversationId;
23
+ const replyRoot = msg.thread_root_message_id || msg.message_id || '';
24
+ return `#${groupName}:${replyRoot}`;
25
+ }
26
+ // group
27
+ const groupName = msg.conversation_name || conversationId;
28
+ return `#${groupName}`;
29
+ }
30
+
31
+ export function buildDebugTaskSuffix(msg) {
32
+ if (msg.task_number == null) return '';
33
+ const status = msg.task_status || 'todo';
34
+ const parts = [`task #${msg.task_number} status=${status}`];
35
+ if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
36
+ const t = msg.task_assignee_type || 'agent';
37
+ const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
38
+ parts.push(`assignee=${t}:${id}`);
39
+ }
40
+ if (msg.task_title) {
41
+ parts.push(`title=${JSON.stringify(msg.task_title)}`);
42
+ }
43
+ return ` [${parts.join(' ')}]`;
44
+ }
45
+
46
+ export function buildDebugReactionsSuffix(msg) {
47
+ const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
48
+ if (entries.length === 0) return '';
49
+ return ` [reactions: ${entries.join('; ')}]`;
50
+ }
51
+
52
+ function normalizeDeliveryReasonForPrompt(reason) {
53
+ return reason === 'thread_follow' ? 'reply_follow' : reason;
54
+ }
55
+
56
+ export function buildDebugEnvelopeHeader(msg) {
57
+ const target = buildEnvelopeTarget(msg);
58
+ const msgId = msg.id || msg.message_id || '';
59
+ const seq = msg.seq != null ? msg.seq : '';
60
+ const time = msg.created_at || new Date().toISOString();
61
+ const type = msg.sender_type || 'human';
62
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
63
+ const convType = msg.conversation_type || 'dm';
64
+ const displayReason = normalizeDeliveryReasonForPrompt(msg.reason || '');
65
+ const reason = displayReason ? ` reason=${displayReason}` : '';
66
+ const recipientRole = String(msg.recipient_conversation_role || msg.recipient_role || '').trim().toLowerCase();
67
+ const role = convType === 'group' && recipientRole ? ` role=${recipientRole}` : '';
68
+ return `[target=${target} msg=${msgId} seq=${seq} time=${time} type=${type}${reason}${role}] @${sender}:`;
69
+ }
70
+
71
+ export const buildEnvelopeHeader = buildDebugEnvelopeHeader;
72
+
73
+ export function buildGroupContextBlock(msg) {
74
+ // Only useful in groups — DMs don't need group-purpose context.
75
+ if ((msg.conversation_type || 'dm') !== 'group') return '';
76
+ const lines = [];
77
+ const name = msg.conversation_name || msg.conversation_display_name || '';
78
+ if (name) lines.push(`Group: #${name}`);
79
+ const description = (msg.conversation_description || '').trim();
80
+ if (description) lines.push(`Purpose: ${description}`);
81
+ if (lines.length === 0) return '';
82
+ return lines.join('\n');
83
+ }
84
+
85
+ export function buildCharterBlock(msg) {
86
+ const charter = (msg.conversation_charter || '').trim();
87
+ if (!charter) return '';
88
+ const conversationId = msg.conversation_id || '';
89
+ // The goal lane (transition deliveries) executes against the charter; its
90
+ // per-step instructions come from the goal-step prompt, so here the charter
91
+ // is just the goal/success spec. The chat lane must NOT run the loop — it
92
+ // only signals goal changes to wake the goal lane.
93
+ const authorityLine = msg.reason === 'transition'
94
+ ? 'This is the goal and success spec for this conversation. Run the current step against it.'
95
+ : hasGoalAuthority(msg)
96
+ ? `This is the goal the backend goal lane is already driving. Handle the incoming message and reply; do not run a goal loop or start gap/execution work yourself. If this message sets, clarifies, or changes the goal, update the charter and then run \`ticlawk goal changed --conversation ${conversationId}\` to wake the goal lane. See GOAL_AUTHORITY.md.`
97
+ : 'Use it as current group goal and role context. The group admin owns charter and dashboard changes unless they explicitly delegate them.';
98
+ return promptBlock(`
99
+ [conversation_goal]
100
+ Current charter for this conversation:
101
+ ${charter}
102
+
103
+ ${authorityLine}
104
+ [/conversation_goal]
105
+ `);
106
+ }
107
+
108
+ export function buildGoalStateBlock(msg) {
109
+ if ((msg.conversation_charter || '').trim()) return '';
110
+ const convType = msg.conversation_type || 'dm';
111
+ const subject = convType === 'group' ? 'This group' : 'This conversation';
112
+ const authorityLine = hasGoalAuthority(msg)
113
+ ? 'If this message may be starting, clarifying, or changing an ongoing goal, read GOAL_AUTHORITY.md "Goal Setup When No Specific Goal Exists" and follow it to propose/confirm the goal before writing charter/dashboard state. If it is clearly one-off, answer normally.'
114
+ : 'The group admin owns goal setup; follow direct mentions or assigned tasks normally.';
115
+ return promptBlock(`
116
+ [conversation_goal]
117
+ ${subject} does not have a chartered goal yet.
118
+ ${authorityLine}
119
+ [/conversation_goal]
120
+ `);
121
+ }
122
+
123
+ function hasGoalAuthority(msg) {
124
+ const convType = msg.conversation_type || 'dm';
125
+ if (convType !== 'group') return true;
126
+ if (msg.recipient_is_conversation_admin === true) return true;
127
+ const recipientRole = String(msg.recipient_conversation_role || msg.recipient_role || '').trim().toLowerCase();
128
+ return recipientRole === 'admin' || recipientRole === 'owner';
129
+ }
130
+
131
+ export function buildQuoteBlock(msg, target = '') {
132
+ const meta = msg.message_metadata || msg.metadata || null;
133
+ const quote = meta && typeof meta === 'object' ? meta.quote : null;
134
+ if (!quote || typeof quote !== 'object') return '';
135
+ const kind = String(quote.kind || '').trim();
136
+ const ref = String(quote.ref || '').trim();
137
+ const snippet = String(quote.snippet || '').trim();
138
+ if (!kind || !ref) return '';
139
+ const fetchHint = kind === 'briefing'
140
+ ? `ticlawk briefing get ${ref}`
141
+ : kind === 'dashboard'
142
+ ? `ticlawk dashboard get --conversation-id ${ref}`
143
+ : kind === 'message'
144
+ ? `ticlawk message read --target ${JSON.stringify(target)} --around ${ref}`
145
+ : '';
146
+ const lines = [
147
+ `The incoming message is a reply to a ${kind}.`,
148
+ `Referenced ${kind}: \`${ref}\``,
149
+ ];
150
+ if (snippet) lines.push(`Quoted snippet: ${snippet}`);
151
+ if (fetchHint) lines.push(`Fetch it if needed: \`${fetchHint}\``);
152
+ return lines.join('\n');
153
+ }
154
+
155
+ function senderDescription(msg) {
156
+ const type = msg.sender_type === 'agent' ? 'an agent' : 'a human user';
157
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
158
+ return sender ? `@${sender}, ${type}` : type;
159
+ }
160
+
161
+ function senderFactDescription(msg) {
162
+ const type = msg.sender_type === 'agent' ? 'agent' : 'human user';
163
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
164
+ return sender ? `@${sender}, ${type}` : type;
165
+ }
166
+
167
+ function conversationLabel(msg, target) {
168
+ const convType = msg.conversation_type || 'dm';
169
+ if (convType === 'dm') return 'a one-on-one conversation';
170
+ return target || 'this group';
171
+ }
172
+
173
+ function buildMessageSummary(msg, target) {
174
+ const convType = msg.conversation_type || 'dm';
175
+ const reason = normalizeDeliveryReasonForPrompt(msg.reason || '');
176
+ const sender = senderDescription(msg);
177
+ const senderFact = senderFactDescription(msg);
178
+ const where = conversationLabel(msg, target);
179
+
180
+ if (convType === 'dm') {
181
+ return `This is a one-on-one message from ${sender}.`;
182
+ }
183
+ if (reason === 'assignment') {
184
+ return `This message assigns or routes work to you in ${where}.\nSender: ${senderFact}`;
185
+ }
186
+ if (reason === 'ambient') {
187
+ return `Sender: ${senderFact}`;
188
+ }
189
+ if (reason === 'mention') {
190
+ return `You were mentioned in ${where} by ${sender}.`;
191
+ }
192
+ if (reason === 'reply_follow') {
193
+ return `This is a reply in ${where} from ${sender}.`;
194
+ }
195
+ if (reason === 'manual') {
196
+ return `This is a manual wake-up or reminder for ${where}.\nSource: ${senderFact}`;
197
+ }
198
+ return `This is a message in ${where} from ${sender}.`;
199
+ }
200
+
201
+ function buildTaskDetailsBlock(msg) {
202
+ if (msg.task_number == null) return '';
203
+ const lines = [`Task #${msg.task_number} is currently \`${msg.task_status || 'todo'}\`.`];
204
+ if (msg.task_title) lines.push(`Task title: ${msg.task_title}`);
205
+ if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
206
+ const t = msg.task_assignee_type || 'agent';
207
+ const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
208
+ lines.push(`Assignee: ${t} \`${id}\``);
209
+ }
210
+ return lines.join('\n');
211
+ }
212
+
213
+ function buildReactionsBlock(msg) {
214
+ const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
215
+ if (entries.length === 0) return '';
216
+ return `Recent reactions: ${entries.join('; ')}`;
217
+ }
218
+
219
+ function buildContentBlock(rawText) {
220
+ const text = String(rawText || '').trim();
221
+ if (!text) return '';
222
+ return promptBlock(`
223
+ Content:
224
+ ${text}
225
+ `);
226
+ }
227
+
228
+ // Wrap each per-turn message with the concrete dynamic context for this
229
+ // delivery. Durable communication rules live in COMMUNICATION.md; this block
230
+ // only carries the current message and its exact reply target.
231
+ export function buildWakePromptText({ messageSummary, target, rawText, groupContext, charterBlock, goalStateBlock, quoteBlock, taskDetails, reactionsBlock }) {
232
+ const contextPrefix = [charterBlock, goalStateBlock, quoteBlock].filter(Boolean).join('\n\n');
233
+ const prefix = contextPrefix ? `${contextPrefix}\n\n` : '';
234
+ const detailBlocks = [
235
+ messageSummary,
236
+ `Reply target: \`${target}\``,
237
+ groupContext,
238
+ taskDetails ? `Task details:\n${taskDetails}` : '',
239
+ reactionsBlock,
240
+ buildContentBlock(rawText),
241
+ ].filter(Boolean).join('\n\n');
242
+ return promptBlock(`
243
+ ${prefix}New message received:
244
+
245
+ ${detailBlocks}
246
+ `);
247
+ }
248
+
249
+ export function buildInboundWakePrompt(msg) {
250
+ const rawText = msg.text || '';
251
+ const baseHeader = buildDebugEnvelopeHeader(msg);
252
+ const header = baseHeader + buildDebugTaskSuffix(msg) + buildDebugReactionsSuffix(msg);
253
+ const target = buildEnvelopeTarget(msg);
254
+ const groupContext = buildGroupContextBlock(msg);
255
+ const charterBlock = buildCharterBlock(msg);
256
+ const goalStateBlock = buildGoalStateBlock(msg);
257
+ const quoteBlock = buildQuoteBlock(msg, target);
258
+ const messageSummary = buildMessageSummary(msg, target);
259
+ const taskDetails = buildTaskDetailsBlock(msg);
260
+ const reactionsBlock = buildReactionsBlock(msg);
261
+ const text = buildWakePromptText({ messageSummary, target, rawText, groupContext, charterBlock, goalStateBlock, quoteBlock, taskDetails, reactionsBlock });
262
+ return {
263
+ header,
264
+ target,
265
+ text,
266
+ rawText,
267
+ };
268
+ }
@@ -23,12 +23,14 @@ import {
23
23
  } from './session.mjs';
24
24
  import { discoverSessions } from './transcripts.mjs';
25
25
  import { buildImageMessageFromInbound } from '../../core/media/inbound.mjs';
26
- import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
26
+ import { emitWorkerEventBestEffort } from '../../core/events/worker-events.mjs';
27
27
  import {
28
28
  shouldStreamRuntime,
29
29
  reportSubprocessFailure,
30
30
  terminalRuntimeFailure,
31
31
  updateBindingRuntimeMeta,
32
+ resolveRuntimeSessionScope,
33
+ buildRuntimeSessionMetaPatch,
32
34
  } from '../../core/runtime-support.mjs';
33
35
 
34
36
  export const claudeCodeRuntime = {
@@ -130,31 +132,35 @@ export const claudeCodeRuntime = {
130
132
  ? await buildImageMessageFromInbound(inbound, 'claude-code')
131
133
  : inbound.text;
132
134
 
133
- // shouldRotate=true means meta.sessionId is missing or invalidated.
135
+ // shouldRotate=true means this conversation has no runtime session
136
+ // yet, or the agent was reset and all scoped sessions are invalid.
134
137
  // We pass sessionId=null so `claude` creates a fresh session; the new
135
138
  // session_id is captured from stream events and persisted below.
136
139
  // Unifying rotate + non-rotate into one path means the standing prompt
137
140
  // is always attached, so the agent uses the CLI to reply on every
138
141
  // turn — including the first.
139
- const shouldRotate = !meta.sessionId || meta.rotatePending;
140
- const targetSessionId = shouldRotate ? null : meta.sessionId;
141
- const errEventSessionId = meta.sessionId || binding.id;
142
+ const sessionScope = resolveRuntimeSessionScope(meta, inbound);
143
+ const targetSessionId = sessionScope.shouldRotate ? null : sessionScope.sessionId;
144
+ const errEventSessionId = targetSessionId || binding.id;
142
145
 
143
146
  try {
144
147
  const claudePath = requireClaudePath(runtimeClaudePath);
145
148
  const claudeVersion = getClaudeCodeRuntimeHealth(claudePath).version || meta.claudeVersion || null;
146
149
  const agentEnv = buildAgentRuntimeEnv({
147
150
  agentId: binding.id,
148
- sessionId: meta.sessionId,
151
+ sessionId: targetSessionId,
149
152
  hostId: binding.runtime_host_id,
153
+ conversationId: inbound.conversationId,
154
+ messageId: inbound.messageId,
155
+ target: inbound.envelopeTarget,
150
156
  });
151
- const appendSystemPrompt = buildStandingPrompt({ agentId: binding.id });
157
+ const appendSystemPrompt = buildStandingPrompt({ agentId: binding.id, inbound });
152
158
  const result = shouldStreamRuntime(this.name, this)
153
159
  ? await this.runTurnStream({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, {
154
160
  appendSystemPrompt,
155
161
  onEvent: async (event) => {
156
162
  if (event?.type === 'turn.started') {
157
- await emitWorkerEvent({
163
+ emitWorkerEventBestEffort({
158
164
  adapter,
159
165
  binding,
160
166
  agent: this.name,
@@ -167,34 +173,19 @@ export const claudeCodeRuntime = {
167
173
  },
168
174
  logger: ctx.logger,
169
175
  });
170
- } else if (event?.type === 'message.delta' && event.text) {
171
- await emitWorkerEvent({
172
- adapter,
173
- binding,
174
- agent: this.name,
175
- sessionId: event.sessionId || targetSessionId || binding.id,
176
- cwd: projectDir,
177
- replyToMessageId: inbound.messageId || null,
178
- event: {
179
- hook_event_name: 'worker.message.delta',
180
- worker_event_name: 'worker.message.delta',
181
- delta: event.text,
182
- },
183
- logger: ctx.logger,
184
- });
185
176
  }
186
177
  },
187
178
  })
188
179
  : await this.runTurn({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
189
180
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
190
- sessionId: result?.sessionId || meta.sessionId,
181
+ ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
182
+ sessionId: result?.sessionId,
183
+ }),
191
184
  runtimePath: claudePath,
192
185
  claudePath,
193
186
  claudeVersion,
194
- rotatePending: false,
195
- lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
196
187
  }, { status: 'connected' });
197
- await emitWorkerEvent({
188
+ emitWorkerEventBestEffort({
198
189
  adapter,
199
190
  binding: nextBinding,
200
191
  agent: this.name,
@@ -209,7 +200,7 @@ export const claudeCodeRuntime = {
209
200
  });
210
201
  return true;
211
202
  } catch (err) {
212
- await emitWorkerEvent({
203
+ emitWorkerEventBestEffort({
213
204
  adapter,
214
205
  binding,
215
206
  agent: this.name,
@@ -239,24 +230,6 @@ export const claudeCodeRuntime = {
239
230
  }
240
231
  },
241
232
 
242
- async reconcileAfterRestart(binding, ctx) {
243
- const meta = binding.runtimeMeta || {};
244
- await emitWorkerEvent({
245
- adapter: ctx.adapter,
246
- binding,
247
- agent: this.name,
248
- sessionId: meta.sessionId || binding.id,
249
- cwd: ensureAgentHome(binding.id) || '',
250
- event: {
251
- hook_event_name: 'Stop',
252
- worker_event_name: 'worker.turn.complete',
253
- reason: 'connector.restart.reconcile',
254
- },
255
- logger: ctx.logger,
256
- });
257
- return 1;
258
- },
259
-
260
233
  // Friendly display name for a CC session (encoded project path →
261
234
  // last meaningful segment). Used when materializing a binding.
262
235
  formatDisplayName(session) {
@@ -231,23 +231,19 @@ export function streamCCPrompt({
231
231
  let seenTurnStart = false;
232
232
  let activeSessionId = sessionId || null;
233
233
  let finalText = '';
234
- let eventChain = Promise.resolve();
235
234
 
236
235
  const emit = (event) => {
237
236
  if (typeof onEvent !== 'function') return;
238
- eventChain = eventChain
237
+ void Promise.resolve()
239
238
  .then(() => onEvent(event))
240
239
  .catch(() => {});
241
- return eventChain;
242
240
  };
243
241
 
244
242
  const settle = (fn, value) => {
245
243
  if (settled) return;
246
244
  settled = true;
247
245
  if (timeout) clearTimeout(timeout);
248
- eventChain
249
- .catch(() => {})
250
- .finally(() => fn(value));
246
+ fn(value);
251
247
  };
252
248
 
253
249
  const parseLine = (line) => {
@@ -267,7 +263,6 @@ export function streamCCPrompt({
267
263
  const deltaText = parsed.event?.delta?.text;
268
264
  if (typeof deltaText === 'string' && deltaText) {
269
265
  finalText += deltaText;
270
- emit({ type: 'message.delta', sessionId: activeSessionId, text: deltaText });
271
266
  }
272
267
  return;
273
268
  }