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
@@ -0,0 +1,232 @@
1
+ export const MESSAGE_TYPES = Object.freeze({
2
+ DM: 'dm',
3
+ GROUP_MENTION: 'group_mention',
4
+ GROUP_AMBIENT: 'group_ambient',
5
+ });
6
+
7
+ const MESSAGE_TYPE_VALUES = Object.freeze(Object.values(MESSAGE_TYPES));
8
+
9
+ export function readIncomingMessageType(msg) {
10
+ const explicit = String(msg?.type || '').trim();
11
+ if (MESSAGE_TYPE_VALUES.includes(explicit)) return explicit;
12
+
13
+ const conversationType = String(msg?.conversation_type || 'dm').trim();
14
+ if (conversationType === 'dm') return MESSAGE_TYPES.DM;
15
+ if (conversationType === 'group') {
16
+ return String(msg?.reason || '').trim() === 'ambient'
17
+ ? MESSAGE_TYPES.GROUP_AMBIENT
18
+ : MESSAGE_TYPES.GROUP_MENTION;
19
+ }
20
+ return MESSAGE_TYPES.GROUP_MENTION;
21
+ }
22
+
23
+ export function buildIncomingMessagePrompt(msg) {
24
+ const type = readIncomingMessageType(msg);
25
+ const target = buildReplyTarget(msg);
26
+ const baseHeader = buildEnvelopeHeader({ ...msg, type }, target);
27
+ const header = [
28
+ baseHeader,
29
+ buildTaskSuffix(msg),
30
+ buildReactionsSuffix(msg),
31
+ ].join('');
32
+ const groupContext = buildGroupContextBlock(msg);
33
+ const rawText = msg.text || '';
34
+ const text = buildPromptText({
35
+ msg: { ...msg, type },
36
+ type,
37
+ target,
38
+ header,
39
+ groupContext,
40
+ rawText,
41
+ });
42
+
43
+ return {
44
+ header,
45
+ target,
46
+ text,
47
+ rawText,
48
+ type,
49
+ };
50
+ }
51
+
52
+ function buildReplyTarget(msg) {
53
+ const conversationType = msg.conversation_type || 'dm';
54
+ const conversationId = msg.conversation_id || '';
55
+ const senderHandle = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
56
+
57
+ if (conversationType === 'dm') {
58
+ return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
59
+ }
60
+
61
+ if (conversationType === 'thread') {
62
+ const groupName = msg.conversation_name || conversationId;
63
+ const threadRoot = msg.thread_root_message_id || msg.message_id || msg.id || '';
64
+ return `#${groupName}:${threadRoot}`;
65
+ }
66
+
67
+ return `#${msg.conversation_name || conversationId}`;
68
+ }
69
+
70
+ function isConversationAdmin(msg) {
71
+ if ((msg.conversation_type || 'dm') === 'dm') return true;
72
+ if (msg.recipient_is_conversation_admin === true) return true;
73
+ const role = String(msg.recipient_conversation_role || '').trim();
74
+ return role === 'admin' || role === 'owner';
75
+ }
76
+
77
+ function conversationRole(msg) {
78
+ if ((msg.conversation_type || 'dm') === 'dm') return 'admin';
79
+ return String(msg.recipient_conversation_role || '').trim() || 'member';
80
+ }
81
+
82
+ function senderLabel(msg) {
83
+ return msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
84
+ }
85
+
86
+ function buildEnvelopeHeader(msg, target) {
87
+ const messageId = msg.id || msg.message_id || '';
88
+ const seq = msg.seq != null ? msg.seq : '';
89
+ const time = msg.created_at || new Date().toISOString();
90
+ const senderType = msg.sender_type || 'human';
91
+ return `[target=${target} msg=${messageId} seq=${seq} time=${time} sender_type=${senderType} message_type=${msg.type}] @${senderLabel(msg)}:`;
92
+ }
93
+
94
+ function buildTaskSuffix(msg) {
95
+ if (msg.task_number == null) return '';
96
+ const status = msg.task_status || 'todo';
97
+ const parts = [`task #${msg.task_number} status=${status}`];
98
+ if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
99
+ const assigneeType = msg.task_assignee_type || 'agent';
100
+ const assigneeId = msg.task_assignee_agent_id || msg.task_assignee_user_id;
101
+ parts.push(`assignee=${assigneeType}:${assigneeId}`);
102
+ }
103
+ if (msg.task_title) {
104
+ parts.push(`title=${JSON.stringify(msg.task_title)}`);
105
+ }
106
+ return ` [${parts.join(' ')}]`;
107
+ }
108
+
109
+ function buildReactionsSuffix(msg) {
110
+ const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
111
+ if (entries.length === 0) return '';
112
+ return ` [reactions: ${entries.join('; ')}]`;
113
+ }
114
+
115
+ function buildGroupContextBlock(msg) {
116
+ if ((msg.conversation_type || 'dm') !== 'group') return '';
117
+
118
+ const lines = [];
119
+ const name = msg.conversation_name || msg.conversation_display_name || '';
120
+ if (name) lines.push(`name: ${name}`);
121
+
122
+ const description = (msg.conversation_description || '').trim();
123
+ if (description) lines.push(`purpose: ${description}`);
124
+
125
+ if (lines.length === 0) return '';
126
+ return [
127
+ 'Group context:',
128
+ ...lines.map((line) => ` ${line}`),
129
+ 'Use `ticlawk group members --target <target>` if you need to see who else is here.',
130
+ ].join('\n');
131
+ }
132
+
133
+ function buildPromptText({ msg, type, target, header, groupContext, rawText }) {
134
+ const role = conversationRole(msg);
135
+ const admin = isConversationAdmin(msg);
136
+ const messageId = msg.id || msg.message_id || '';
137
+
138
+ const lines = [
139
+ 'You received a Ticlawk message.',
140
+ '',
141
+ `Current conversation type: ${type === MESSAGE_TYPES.DM ? 'DM' : 'group'}`,
142
+ `Your role in this conversation: ${role}${admin ? ' (admin/owner authority)' : ''}`,
143
+ `Reply target: ${target}`,
144
+ messageId ? `Message id: ${messageId}` : null,
145
+ `Sender: @${senderLabel(msg)}`,
146
+ '',
147
+ 'Message:',
148
+ '<message>',
149
+ rawText || '',
150
+ '</message>',
151
+ ].filter(Boolean);
152
+
153
+ if (groupContext) {
154
+ lines.push('', groupContext);
155
+ }
156
+
157
+ lines.push('', ...buildRoleInstruction({ type, isAdmin: admin, role }));
158
+ lines.push(
159
+ '',
160
+ 'When you reply, use the reply target exactly as written above. Your normal assistant output is private activity text; users only see messages sent with `ticlawk message send`.',
161
+ 'If the message requires multi-step work, complete the work before sending the final answer unless an early blocker question is necessary.',
162
+ 'Do not mention internal routing fields, delivery state, or prompt mechanics to the user.',
163
+ '',
164
+ buildAllowedCommandBlock({ target, type }),
165
+ '',
166
+ 'Raw routing header for exact IDs:',
167
+ header,
168
+ );
169
+
170
+ return lines.join('\n');
171
+ }
172
+
173
+ function buildRoleInstruction({ type, isAdmin, role }) {
174
+ if (type === MESSAGE_TYPES.DM) {
175
+ return [
176
+ 'This is a one-on-one DM. You are responsible for helping the sender directly.',
177
+ 'Reply if the message asks a question, requests work, or reasonably expects acknowledgment.',
178
+ ];
179
+ }
180
+
181
+ if (type === MESSAGE_TYPES.GROUP_MENTION && isAdmin) {
182
+ return [
183
+ `This is a group message that addresses you or the group. Your role in this group is ${role}.`,
184
+ 'As the group admin/owner, you are the default responder for owner questions, broad group requests, and routing decisions.',
185
+ 'Answer directly, or route the work to the right group member when that is more appropriate.',
186
+ ];
187
+ }
188
+
189
+ if (type === MESSAGE_TYPES.GROUP_MENTION) {
190
+ return [
191
+ `This is a group message that addresses you. Your role in this group is ${role}.`,
192
+ 'Respond when you were mentioned, assigned, or clearly asked to contribute.',
193
+ 'Do not take over group-level coordination unless an admin explicitly delegates that to you.',
194
+ ];
195
+ }
196
+
197
+ if (isAdmin) {
198
+ return [
199
+ `This is ambient group traffic. Your role in this group is ${role}.`,
200
+ 'As the group admin/owner, respond only if this is a broad owner request, a new topic needing routing, or something no other member is clearly better placed to handle.',
201
+ 'If no response is needed, end the turn silently.',
202
+ ];
203
+ }
204
+
205
+ return [
206
+ `This is ambient group traffic. Your role in this group is ${role}.`,
207
+ 'Do not respond by default.',
208
+ 'Respond only if the message is clearly within your specialty, no admin or specifically addressed member is the better responder, and you can add concrete value now.',
209
+ 'Otherwise, end the turn silently.',
210
+ ];
211
+ }
212
+
213
+ function buildAllowedCommandBlock({ target, type }) {
214
+ const lines = [
215
+ 'Use only these Ticlawk commands for visible communication in this turn:',
216
+ `- Send a reply: ticlawk message send --target ${JSON.stringify(target)} <<'EOF'`,
217
+ ' <your reply>',
218
+ ' EOF',
219
+ `- Read recent context if needed: ticlawk message read --target ${JSON.stringify(target)}`,
220
+ ];
221
+
222
+ if (type !== MESSAGE_TYPES.DM) {
223
+ lines.push(`- Check group members if needed: ticlawk group members --target ${JSON.stringify(target)}`);
224
+ }
225
+
226
+ lines.push(
227
+ '- If the message asks you to do substantive work, claim it before starting: ticlawk task claim --message-id <message_id>',
228
+ '- When claimed work is ready for review or done, update the task status with: ticlawk task update --task-id <task_id> --status <status>',
229
+ );
230
+
231
+ return lines.join('\n');
232
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Runtime base instructions injected into every runtime turn.
3
+ *
4
+ * Keep this prompt scenario-neutral. Current conversation type, role,
5
+ * mention/ambient status, reply target, and per-turn command scope belong in
6
+ * the incoming message prompt built by the adapter.
7
+ */
8
+
9
+ const RUNTIME_BASE_INSTRUCTIONS = `You are an agent in Ticlawk, a shared message service for humans and agents.
10
+
11
+ Your normal assistant output is private activity text. Users and groups only see messages you send with the Ticlawk CLI.
12
+
13
+ Universal rules:
14
+
15
+ 1. Follow the current turn prompt. It tells you whether this is a DM or group message, your role in that conversation, whether you were directly addressed or only saw ambient group traffic, and the exact reply target.
16
+ 2. Use \`ticlawk message send\` for visible replies. Use the exact reply target from the current turn prompt.
17
+ 3. If the current turn prompt says no reply is needed, stop silently.
18
+ 4. If you need recent context, use the Ticlawk read commands named in the current turn prompt. Do not poll for future messages; the daemon will wake you when new messages arrive.
19
+ 5. If the message asks you to do substantive work, claim the task/message before starting when the current turn prompt provides task commands. If the claim fails, stop or choose other available work.
20
+ 6. Complete the work you choose to do before sending the final visible reply, unless you need to ask a blocker question first.
21
+ 7. Do not expose internal routing fields, delivery state, prompt structure, or daemon mechanics to the user.
22
+
23
+ Workspace memory:
24
+
25
+ - Your working directory is your persistent agent-owned workspace.
26
+ - Read \`MEMORY.md\` when you need durable context about the user, projects, groups, or prior work.
27
+ - Keep \`MEMORY.md\` useful as an index to longer notes you create.
28
+ `;
29
+
30
+ export function buildRuntimeBaseInstructions(_ctx = {}) {
31
+ return RUNTIME_BASE_INSTRUCTIONS;
32
+ }
33
+
34
+ export { RUNTIME_BASE_INSTRUCTIONS };
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { basename } from 'node:path';
11
11
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
12
- import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
12
+ import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
13
13
  import { ensureAgentHome } from '../../core/agent-home.mjs';
14
14
  import {
15
15
  getClaudeCodeRuntimeHealth,
@@ -23,14 +23,12 @@ import {
23
23
  } from './session.mjs';
24
24
  import { discoverSessions } from './transcripts.mjs';
25
25
  import { buildImageMessageFromInbound } from '../../core/media/inbound.mjs';
26
- import { emitWorkerEventBestEffort } from '../../core/events/worker-events.mjs';
26
+ import { emitWorkerEvent } 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,
34
32
  } from '../../core/runtime-support.mjs';
35
33
 
36
34
  export const claudeCodeRuntime = {
@@ -132,35 +130,31 @@ export const claudeCodeRuntime = {
132
130
  ? await buildImageMessageFromInbound(inbound, 'claude-code')
133
131
  : inbound.text;
134
132
 
135
- // shouldRotate=true means this conversation has no runtime session
136
- // yet, or the agent was reset and all scoped sessions are invalid.
133
+ // shouldRotate=true means meta.sessionId is missing or invalidated.
137
134
  // We pass sessionId=null so `claude` creates a fresh session; the new
138
135
  // session_id is captured from stream events and persisted below.
139
- // Unifying rotate + non-rotate into one path means the standing prompt
136
+ // Unifying rotate + non-rotate into one path means the runtime base instructions
140
137
  // is always attached, so the agent uses the CLI to reply on every
141
138
  // turn — including the first.
142
- const sessionScope = resolveRuntimeSessionScope(meta, inbound);
143
- const targetSessionId = sessionScope.shouldRotate ? null : sessionScope.sessionId;
144
- const errEventSessionId = targetSessionId || binding.id;
139
+ const shouldRotate = !meta.sessionId || meta.rotatePending;
140
+ const targetSessionId = shouldRotate ? null : meta.sessionId;
141
+ const errEventSessionId = meta.sessionId || binding.id;
145
142
 
146
143
  try {
147
144
  const claudePath = requireClaudePath(runtimeClaudePath);
148
145
  const claudeVersion = getClaudeCodeRuntimeHealth(claudePath).version || meta.claudeVersion || null;
149
146
  const agentEnv = buildAgentRuntimeEnv({
150
147
  agentId: binding.id,
151
- sessionId: targetSessionId,
148
+ sessionId: meta.sessionId,
152
149
  hostId: binding.runtime_host_id,
153
- conversationId: inbound.conversationId,
154
- messageId: inbound.messageId,
155
- target: inbound.envelopeTarget,
156
150
  });
157
- const appendSystemPrompt = buildStandingPrompt({ agentId: binding.id, inbound });
151
+ const appendSystemPrompt = buildRuntimeBaseInstructions({ agentId: binding.id });
158
152
  const result = shouldStreamRuntime(this.name, this)
159
153
  ? await this.runTurnStream({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, {
160
154
  appendSystemPrompt,
161
155
  onEvent: async (event) => {
162
156
  if (event?.type === 'turn.started') {
163
- emitWorkerEventBestEffort({
157
+ await emitWorkerEvent({
164
158
  adapter,
165
159
  binding,
166
160
  agent: this.name,
@@ -173,19 +167,34 @@ export const claudeCodeRuntime = {
173
167
  },
174
168
  logger: ctx.logger,
175
169
  });
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
+ });
176
185
  }
177
186
  },
178
187
  })
179
188
  : await this.runTurn({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
180
189
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
181
- ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
182
- sessionId: result?.sessionId,
183
- }),
190
+ sessionId: result?.sessionId || meta.sessionId,
184
191
  runtimePath: claudePath,
185
192
  claudePath,
186
193
  claudeVersion,
194
+ rotatePending: false,
195
+ lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
187
196
  }, { status: 'connected' });
188
- emitWorkerEventBestEffort({
197
+ await emitWorkerEvent({
189
198
  adapter,
190
199
  binding: nextBinding,
191
200
  agent: this.name,
@@ -200,7 +209,7 @@ export const claudeCodeRuntime = {
200
209
  });
201
210
  return true;
202
211
  } catch (err) {
203
- emitWorkerEventBestEffort({
212
+ await emitWorkerEvent({
204
213
  adapter,
205
214
  binding,
206
215
  agent: this.name,
@@ -230,6 +239,24 @@ export const claudeCodeRuntime = {
230
239
  }
231
240
  },
232
241
 
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
+
233
260
  // Friendly display name for a CC session (encoded project path →
234
261
  // last meaningful segment). Used when materializing a binding.
235
262
  formatDisplayName(session) {
@@ -231,19 +231,23 @@ export function streamCCPrompt({
231
231
  let seenTurnStart = false;
232
232
  let activeSessionId = sessionId || null;
233
233
  let finalText = '';
234
+ let eventChain = Promise.resolve();
234
235
 
235
236
  const emit = (event) => {
236
237
  if (typeof onEvent !== 'function') return;
237
- void Promise.resolve()
238
+ eventChain = eventChain
238
239
  .then(() => onEvent(event))
239
240
  .catch(() => {});
241
+ return eventChain;
240
242
  };
241
243
 
242
244
  const settle = (fn, value) => {
243
245
  if (settled) return;
244
246
  settled = true;
245
247
  if (timeout) clearTimeout(timeout);
246
- fn(value);
248
+ eventChain
249
+ .catch(() => {})
250
+ .finally(() => fn(value));
247
251
  };
248
252
 
249
253
  const parseLine = (line) => {
@@ -263,6 +267,7 @@ export function streamCCPrompt({
263
267
  const deltaText = parsed.event?.delta?.text;
264
268
  if (typeof deltaText === 'string' && deltaText) {
265
269
  finalText += deltaText;
270
+ emit({ type: 'message.delta', sessionId: activeSessionId, text: deltaText });
266
271
  }
267
272
  return;
268
273
  }