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
@@ -0,0 +1,261 @@
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 authorityLine = hasGoalAuthority(msg)
89
+ ? 'Use it as the current goal and role spec. If the owner appears to change the goal, read GOAL_AUTHORITY.md and follow the goal-change flow before updating state.'
90
+ : 'Use it as current group goal and role context. The group admin owns charter and dashboard changes unless they explicitly delegate them.';
91
+ return promptBlock(`
92
+ [conversation_goal]
93
+ Current charter for this conversation:
94
+ ${charter}
95
+
96
+ ${authorityLine}
97
+ [/conversation_goal]
98
+ `);
99
+ }
100
+
101
+ export function buildGoalStateBlock(msg) {
102
+ if ((msg.conversation_charter || '').trim()) return '';
103
+ const convType = msg.conversation_type || 'dm';
104
+ const subject = convType === 'group' ? 'This group' : 'This conversation';
105
+ const authorityLine = hasGoalAuthority(msg)
106
+ ? '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.'
107
+ : 'The group admin owns goal setup; follow direct mentions or assigned tasks normally.';
108
+ return promptBlock(`
109
+ [conversation_goal]
110
+ ${subject} does not have a chartered goal yet.
111
+ ${authorityLine}
112
+ [/conversation_goal]
113
+ `);
114
+ }
115
+
116
+ function hasGoalAuthority(msg) {
117
+ const convType = msg.conversation_type || 'dm';
118
+ if (convType !== 'group') return true;
119
+ if (msg.recipient_is_conversation_admin === true) return true;
120
+ const recipientRole = String(msg.recipient_conversation_role || msg.recipient_role || '').trim().toLowerCase();
121
+ return recipientRole === 'admin' || recipientRole === 'owner';
122
+ }
123
+
124
+ export function buildQuoteBlock(msg, target = '') {
125
+ const meta = msg.message_metadata || msg.metadata || null;
126
+ const quote = meta && typeof meta === 'object' ? meta.quote : null;
127
+ if (!quote || typeof quote !== 'object') return '';
128
+ const kind = String(quote.kind || '').trim();
129
+ const ref = String(quote.ref || '').trim();
130
+ const snippet = String(quote.snippet || '').trim();
131
+ if (!kind || !ref) return '';
132
+ const fetchHint = kind === 'briefing'
133
+ ? `ticlawk briefing get ${ref}`
134
+ : kind === 'dashboard'
135
+ ? `ticlawk dashboard get --conversation-id ${ref}`
136
+ : kind === 'message'
137
+ ? `ticlawk message read --target ${JSON.stringify(target)} --around ${ref}`
138
+ : '';
139
+ const lines = [
140
+ `The incoming message is a reply to a ${kind}.`,
141
+ `Referenced ${kind}: \`${ref}\``,
142
+ ];
143
+ if (snippet) lines.push(`Quoted snippet: ${snippet}`);
144
+ if (fetchHint) lines.push(`Fetch it if needed: \`${fetchHint}\``);
145
+ return lines.join('\n');
146
+ }
147
+
148
+ function senderDescription(msg) {
149
+ const type = msg.sender_type === 'agent' ? 'an agent' : 'a human user';
150
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
151
+ return sender ? `@${sender}, ${type}` : type;
152
+ }
153
+
154
+ function senderFactDescription(msg) {
155
+ const type = msg.sender_type === 'agent' ? 'agent' : 'human user';
156
+ const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
157
+ return sender ? `@${sender}, ${type}` : type;
158
+ }
159
+
160
+ function conversationLabel(msg, target) {
161
+ const convType = msg.conversation_type || 'dm';
162
+ if (convType === 'dm') return 'a one-on-one conversation';
163
+ return target || 'this group';
164
+ }
165
+
166
+ function buildMessageSummary(msg, target) {
167
+ const convType = msg.conversation_type || 'dm';
168
+ const reason = normalizeDeliveryReasonForPrompt(msg.reason || '');
169
+ const sender = senderDescription(msg);
170
+ const senderFact = senderFactDescription(msg);
171
+ const where = conversationLabel(msg, target);
172
+
173
+ if (convType === 'dm') {
174
+ return `This is a one-on-one message from ${sender}.`;
175
+ }
176
+ if (reason === 'assignment') {
177
+ return `This message assigns or routes work to you in ${where}.\nSender: ${senderFact}`;
178
+ }
179
+ if (reason === 'ambient') {
180
+ return `Sender: ${senderFact}`;
181
+ }
182
+ if (reason === 'mention') {
183
+ return `You were mentioned in ${where} by ${sender}.`;
184
+ }
185
+ if (reason === 'reply_follow') {
186
+ return `This is a reply in ${where} from ${sender}.`;
187
+ }
188
+ if (reason === 'manual') {
189
+ return `This is a manual wake-up or reminder for ${where}.\nSource: ${senderFact}`;
190
+ }
191
+ return `This is a message in ${where} from ${sender}.`;
192
+ }
193
+
194
+ function buildTaskDetailsBlock(msg) {
195
+ if (msg.task_number == null) return '';
196
+ const lines = [`Task #${msg.task_number} is currently \`${msg.task_status || 'todo'}\`.`];
197
+ if (msg.task_title) lines.push(`Task title: ${msg.task_title}`);
198
+ if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
199
+ const t = msg.task_assignee_type || 'agent';
200
+ const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
201
+ lines.push(`Assignee: ${t} \`${id}\``);
202
+ }
203
+ return lines.join('\n');
204
+ }
205
+
206
+ function buildReactionsBlock(msg) {
207
+ const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
208
+ if (entries.length === 0) return '';
209
+ return `Recent reactions: ${entries.join('; ')}`;
210
+ }
211
+
212
+ function buildContentBlock(rawText) {
213
+ const text = String(rawText || '').trim();
214
+ if (!text) return '';
215
+ return promptBlock(`
216
+ Content:
217
+ ${text}
218
+ `);
219
+ }
220
+
221
+ // Wrap each per-turn message with the concrete dynamic context for this
222
+ // delivery. Durable communication rules live in COMMUNICATION.md; this block
223
+ // only carries the current message and its exact reply target.
224
+ export function buildWakePromptText({ messageSummary, target, rawText, groupContext, charterBlock, goalStateBlock, quoteBlock, taskDetails, reactionsBlock }) {
225
+ const contextPrefix = [charterBlock, goalStateBlock, quoteBlock].filter(Boolean).join('\n\n');
226
+ const prefix = contextPrefix ? `${contextPrefix}\n\n` : '';
227
+ const detailBlocks = [
228
+ messageSummary,
229
+ `Reply target: \`${target}\``,
230
+ groupContext,
231
+ taskDetails ? `Task details:\n${taskDetails}` : '',
232
+ reactionsBlock,
233
+ buildContentBlock(rawText),
234
+ ].filter(Boolean).join('\n\n');
235
+ return promptBlock(`
236
+ ${prefix}New message received:
237
+
238
+ ${detailBlocks}
239
+ `);
240
+ }
241
+
242
+ export function buildInboundWakePrompt(msg) {
243
+ const rawText = msg.text || '';
244
+ const baseHeader = buildDebugEnvelopeHeader(msg);
245
+ const header = baseHeader + buildDebugTaskSuffix(msg) + buildDebugReactionsSuffix(msg);
246
+ const target = buildEnvelopeTarget(msg);
247
+ const groupContext = buildGroupContextBlock(msg);
248
+ const charterBlock = buildCharterBlock(msg);
249
+ const goalStateBlock = buildGoalStateBlock(msg);
250
+ const quoteBlock = buildQuoteBlock(msg, target);
251
+ const messageSummary = buildMessageSummary(msg, target);
252
+ const taskDetails = buildTaskDetailsBlock(msg);
253
+ const reactionsBlock = buildReactionsBlock(msg);
254
+ const text = buildWakePromptText({ messageSummary, target, rawText, groupContext, charterBlock, goalStateBlock, quoteBlock, taskDetails, reactionsBlock });
255
+ return {
256
+ header,
257
+ target,
258
+ text,
259
+ rawText,
260
+ };
261
+ }
@@ -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
  }
@@ -5,7 +5,10 @@
5
5
  * and discover local sessions.
6
6
  */
7
7
 
8
- import { basename } from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { mkdirSync, writeFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { basename, join } from 'node:path';
9
12
  import {
10
13
  createCodexSession,
11
14
  runCodexPrompt,
@@ -18,18 +21,97 @@ import {
18
21
  requireCodexPath,
19
22
  } from './session.mjs';
20
23
  import { buildCodexInputFromInbound } from '../../core/media/inbound.mjs';
21
- import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
24
+ import { emitWorkerEventBestEffort } from '../../core/events/worker-events.mjs';
22
25
  import {
23
26
  shouldStreamRuntime,
24
- createDeltaAggregator,
25
27
  reportSubprocessFailure,
26
28
  terminalRuntimeFailure,
27
29
  updateBindingRuntimeMeta,
28
30
  isRuntimeGatewayFailure,
31
+ resolveRuntimeSessionScope,
32
+ buildRuntimeSessionMetaPatch,
29
33
  } from '../../core/runtime-support.mjs';
30
34
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
31
35
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
32
36
  import { ensureAgentHome } from '../../core/agent-home.mjs';
37
+ import { debugError, debugLog } from '../../core/logger.mjs';
38
+
39
+ function parseBooleanish(value) {
40
+ if (value === undefined || value === null || value === '') return false;
41
+ return ['1', 'true', 'on', 'yes'].includes(String(value).trim().toLowerCase());
42
+ }
43
+
44
+ function sha256(text) {
45
+ return createHash('sha256').update(String(text || ''), 'utf8').digest('hex');
46
+ }
47
+
48
+ function fileSafeId(value) {
49
+ return String(value || 'unknown')
50
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
51
+ .replace(/^-+|-+$/g, '')
52
+ .slice(0, 80) || 'unknown';
53
+ }
54
+
55
+ function writePromptSnapshot({
56
+ binding,
57
+ inbound,
58
+ agentHome,
59
+ targetSessionId,
60
+ shouldRotate,
61
+ developerInstructions,
62
+ message,
63
+ input,
64
+ }) {
65
+ if (!parseBooleanish(process.env.TICLAWK_LOG_RUNTIME_PROMPTS)) return;
66
+
67
+ try {
68
+ const rootDir = process.env.TICLAWK_RUNTIME_PROMPT_LOG_DIR
69
+ || join(homedir(), '.ticlawk', 'prompt-logs');
70
+ const dir = join(rootDir, fileSafeId(binding?.id));
71
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
73
+ const messageId = fileSafeId(inbound?.messageId);
74
+ const filePath = join(dir, `${stamp}-${messageId}.json`);
75
+ const payload = {
76
+ createdAt: new Date().toISOString(),
77
+ runtime: 'codex',
78
+ agentId: binding?.id || null,
79
+ conversationId: inbound?.conversationId || null,
80
+ messageId: inbound?.messageId || null,
81
+ target: inbound?.envelopeTarget || null,
82
+ action: inbound?.action || null,
83
+ sessionId: targetSessionId || null,
84
+ shouldRotate: Boolean(shouldRotate),
85
+ agentHome,
86
+ hashes: {
87
+ developerInstructionsSha256: sha256(developerInstructions),
88
+ messageSha256: sha256(message),
89
+ },
90
+ developerInstructions,
91
+ message,
92
+ input: input || null,
93
+ };
94
+ writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
95
+ debugLog('codex', 'prompt.snapshot', {
96
+ agentId: binding?.id,
97
+ conversationId: inbound?.conversationId,
98
+ messageId: inbound?.messageId,
99
+ target: inbound?.envelopeTarget,
100
+ path: filePath,
101
+ developerInstructionChars: String(developerInstructions || '').length,
102
+ messageChars: String(message || '').length,
103
+ developerInstructionsSha256: payload.hashes.developerInstructionsSha256,
104
+ messageSha256: payload.hashes.messageSha256,
105
+ });
106
+ } catch (err) {
107
+ debugError('codex', 'prompt.snapshot.failed', {
108
+ agentId: binding?.id,
109
+ conversationId: inbound?.conversationId,
110
+ messageId: inbound?.messageId,
111
+ error: err?.message || String(err),
112
+ });
113
+ }
114
+ }
33
115
 
34
116
  export const codexRuntime = {
35
117
  name: 'codex',
@@ -140,46 +222,42 @@ export const codexRuntime = {
140
222
  ? (codexInput.find((item) => item?.type === 'text')?.text || inbound.text || '(image attached)')
141
223
  : inbound.text;
142
224
 
143
- const shouldRotate = !meta.sessionId || meta.rotatePending;
144
- const deltaAggregator = createDeltaAggregator({
145
- flushDelta: async ({ text, sessionId, turnId, cwd }) => {
146
- await emitWorkerEvent({
147
- adapter,
148
- binding,
149
- agent: this.name,
150
- sessionId: sessionId || meta.sessionId || binding.id,
151
- turnId: turnId || null,
152
- cwd: cwd || agentHome,
153
- replyToMessageId: inbound.messageId || null,
154
- event: {
155
- hook_event_name: 'worker.message.delta',
156
- worker_event_name: 'worker.message.delta',
157
- delta: text,
158
- },
159
- logger: ctx.logger,
160
- });
161
- },
162
- });
225
+ const sessionScope = resolveRuntimeSessionScope(meta, inbound);
226
+ const shouldRotate = sessionScope.shouldRotate;
227
+ const targetSessionId = shouldRotate ? null : sessionScope.sessionId;
163
228
  try {
164
229
  const runtimeCodexPath = requireCodexPath(meta.codexPath || meta.runtimePath);
165
230
  const runtimeCodexVersion = getCodexRuntimeHealth(runtimeCodexPath).version || meta.codexVersion || null;
166
231
  const agentEnv = buildAgentRuntimeEnv({
167
232
  agentId: binding.id,
168
- sessionId: meta.sessionId,
233
+ sessionId: targetSessionId,
169
234
  hostId: binding.runtime_host_id,
235
+ conversationId: inbound.conversationId,
236
+ messageId: inbound.messageId,
237
+ target: inbound.envelopeTarget,
238
+ });
239
+ const developerInstructions = buildStandingPrompt({ agentId: binding.id, inbound });
240
+ writePromptSnapshot({
241
+ binding,
242
+ inbound,
243
+ agentHome,
244
+ targetSessionId,
245
+ shouldRotate,
246
+ developerInstructions,
247
+ message,
248
+ input: codexInput,
170
249
  });
171
- const developerInstructions = buildStandingPrompt({ agentId: binding.id });
172
250
  const result = (shouldRotate || inbound.action === 'image' || shouldStreamRuntime(this.name, this))
173
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
251
+ ? await this.runTurnStream({ sessionId: targetSessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
174
252
  input: codexInput,
175
253
  developerInstructions,
176
254
  onEvent: async (event) => {
177
255
  if (event?.type === 'turn.started') {
178
- await emitWorkerEvent({
256
+ emitWorkerEventBestEffort({
179
257
  adapter,
180
258
  binding,
181
259
  agent: this.name,
182
- sessionId: event.sessionId || meta.sessionId || binding.id,
260
+ sessionId: event.sessionId || targetSessionId || binding.id,
183
261
  turnId: event.turnId || null,
184
262
  cwd: agentHome,
185
263
  replyToMessageId: inbound.messageId || null,
@@ -189,34 +267,27 @@ export const codexRuntime = {
189
267
  },
190
268
  logger: ctx.logger,
191
269
  });
192
- } else if (event?.type === 'message.delta' && event.text) {
193
- deltaAggregator.push(event.text, {
194
- sessionId: event.sessionId || meta.sessionId || binding.id,
195
- turnId: event.turnId || null,
196
- cwd: agentHome,
197
- });
198
270
  }
199
271
  },
200
272
  })
201
- : await this.runTurn({ sessionId: meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
273
+ : await this.runTurn({ sessionId: targetSessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
202
274
  input: codexInput,
203
275
  });
204
276
 
205
- await deltaAggregator.flush();
206
277
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
207
- sessionId: result?.sessionId || meta.sessionId,
208
- path: result?.path || meta.path || null,
278
+ ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
279
+ sessionId: result?.sessionId,
280
+ path: result?.path,
281
+ }),
209
282
  runtimePath: runtimeCodexPath,
210
283
  codexPath: runtimeCodexPath,
211
284
  codexVersion: runtimeCodexVersion,
212
- rotatePending: false,
213
- lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
214
285
  }, { status: 'connected' });
215
- await emitWorkerEvent({
286
+ emitWorkerEventBestEffort({
216
287
  adapter,
217
288
  binding: nextBinding,
218
289
  agent: this.name,
219
- sessionId: result?.sessionId || meta.sessionId || binding.id,
290
+ sessionId: result?.sessionId || targetSessionId || binding.id,
220
291
  turnId: result?.turnId || null,
221
292
  cwd: result?.cwd || agentHome,
222
293
  replyToMessageId: inbound.messageId || null,
@@ -228,7 +299,6 @@ export const codexRuntime = {
228
299
  });
229
300
  return true;
230
301
  } catch (err) {
231
- await deltaAggregator.flush().catch(() => {});
232
302
  const failureInfo = err?.info || {
233
303
  ok: false,
234
304
  kind: 'exit-error',
@@ -243,11 +313,11 @@ export const codexRuntime = {
243
313
  lastGatewayFailureReason: failureInfo.errorMessage || err?.message || 'Codex app-server error',
244
314
  }, { status: 'degraded' }).catch(() => binding);
245
315
  }
246
- await emitWorkerEvent({
316
+ emitWorkerEventBestEffort({
247
317
  adapter,
248
318
  binding: failureBinding,
249
319
  agent: this.name,
250
- sessionId: meta.sessionId || binding.id,
320
+ sessionId: targetSessionId || binding.id,
251
321
  turnId: failureInfo?.turnId || null,
252
322
  cwd: agentHome,
253
323
  replyToMessageId: inbound.messageId || null,
@@ -269,24 +339,6 @@ export const codexRuntime = {
269
339
  }
270
340
  },
271
341
 
272
- async reconcileAfterRestart(binding, ctx) {
273
- const meta = binding.runtimeMeta || {};
274
- await emitWorkerEvent({
275
- adapter: ctx.adapter,
276
- binding,
277
- agent: this.name,
278
- sessionId: meta.sessionId || binding.id,
279
- cwd: ensureAgentHome(binding.id) || '',
280
- event: {
281
- hook_event_name: 'Stop',
282
- worker_event_name: 'worker.turn.complete',
283
- reason: 'connector.restart.reconcile',
284
- },
285
- logger: ctx.logger,
286
- });
287
- return 1;
288
- },
289
-
290
342
  sessionsDir: CODEX_SESSIONS_DIR,
291
343
  maxAgeMs: CODEX_MAX_AGE_MS,
292
344
  };