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
@@ -5,10 +5,7 @@
5
5
  * and discover local sessions.
6
6
  */
7
7
 
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';
8
+ import { basename } from 'node:path';
12
9
  import {
13
10
  createCodexSession,
14
11
  runCodexPrompt,
@@ -21,97 +18,18 @@ import {
21
18
  requireCodexPath,
22
19
  } from './session.mjs';
23
20
  import { buildCodexInputFromInbound } from '../../core/media/inbound.mjs';
24
- import { emitWorkerEventBestEffort } from '../../core/events/worker-events.mjs';
21
+ import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
25
22
  import {
26
23
  shouldStreamRuntime,
24
+ createDeltaAggregator,
27
25
  reportSubprocessFailure,
28
26
  terminalRuntimeFailure,
29
27
  updateBindingRuntimeMeta,
30
28
  isRuntimeGatewayFailure,
31
- resolveRuntimeSessionScope,
32
- buildRuntimeSessionMetaPatch,
33
29
  } from '../../core/runtime-support.mjs';
34
30
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
35
- import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
31
+ import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
36
32
  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
- }
115
33
 
116
34
  export const codexRuntime = {
117
35
  name: 'codex',
@@ -222,42 +140,46 @@ export const codexRuntime = {
222
140
  ? (codexInput.find((item) => item?.type === 'text')?.text || inbound.text || '(image attached)')
223
141
  : inbound.text;
224
142
 
225
- const sessionScope = resolveRuntimeSessionScope(meta, inbound);
226
- const shouldRotate = sessionScope.shouldRotate;
227
- const targetSessionId = shouldRotate ? null : sessionScope.sessionId;
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
+ });
228
163
  try {
229
164
  const runtimeCodexPath = requireCodexPath(meta.codexPath || meta.runtimePath);
230
165
  const runtimeCodexVersion = getCodexRuntimeHealth(runtimeCodexPath).version || meta.codexVersion || null;
231
166
  const agentEnv = buildAgentRuntimeEnv({
232
167
  agentId: binding.id,
233
- sessionId: targetSessionId,
168
+ sessionId: shouldRotate ? null : meta.sessionId,
234
169
  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,
249
170
  });
171
+ const developerInstructions = buildRuntimeBaseInstructions({ agentId: binding.id });
250
172
  const result = (shouldRotate || inbound.action === 'image' || shouldStreamRuntime(this.name, this))
251
- ? await this.runTurnStream({ sessionId: targetSessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
173
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
252
174
  input: codexInput,
253
175
  developerInstructions,
254
176
  onEvent: async (event) => {
255
177
  if (event?.type === 'turn.started') {
256
- emitWorkerEventBestEffort({
178
+ await emitWorkerEvent({
257
179
  adapter,
258
180
  binding,
259
181
  agent: this.name,
260
- sessionId: event.sessionId || targetSessionId || binding.id,
182
+ sessionId: event.sessionId || meta.sessionId || binding.id,
261
183
  turnId: event.turnId || null,
262
184
  cwd: agentHome,
263
185
  replyToMessageId: inbound.messageId || null,
@@ -267,27 +189,34 @@ export const codexRuntime = {
267
189
  },
268
190
  logger: ctx.logger,
269
191
  });
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
+ });
270
198
  }
271
199
  },
272
200
  })
273
- : await this.runTurn({ sessionId: targetSessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
201
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
274
202
  input: codexInput,
275
203
  });
276
204
 
205
+ await deltaAggregator.flush();
277
206
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
278
- ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
279
- sessionId: result?.sessionId,
280
- path: result?.path,
281
- }),
207
+ sessionId: result?.sessionId || meta.sessionId,
208
+ path: result?.path || meta.path || null,
282
209
  runtimePath: runtimeCodexPath,
283
210
  codexPath: runtimeCodexPath,
284
211
  codexVersion: runtimeCodexVersion,
212
+ rotatePending: false,
213
+ lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
285
214
  }, { status: 'connected' });
286
- emitWorkerEventBestEffort({
215
+ await emitWorkerEvent({
287
216
  adapter,
288
217
  binding: nextBinding,
289
218
  agent: this.name,
290
- sessionId: result?.sessionId || targetSessionId || binding.id,
219
+ sessionId: result?.sessionId || meta.sessionId || binding.id,
291
220
  turnId: result?.turnId || null,
292
221
  cwd: result?.cwd || agentHome,
293
222
  replyToMessageId: inbound.messageId || null,
@@ -299,6 +228,7 @@ export const codexRuntime = {
299
228
  });
300
229
  return true;
301
230
  } catch (err) {
231
+ await deltaAggregator.flush().catch(() => {});
302
232
  const failureInfo = err?.info || {
303
233
  ok: false,
304
234
  kind: 'exit-error',
@@ -313,11 +243,11 @@ export const codexRuntime = {
313
243
  lastGatewayFailureReason: failureInfo.errorMessage || err?.message || 'Codex app-server error',
314
244
  }, { status: 'degraded' }).catch(() => binding);
315
245
  }
316
- emitWorkerEventBestEffort({
246
+ await emitWorkerEvent({
317
247
  adapter,
318
248
  binding: failureBinding,
319
249
  agent: this.name,
320
- sessionId: targetSessionId || binding.id,
250
+ sessionId: meta.sessionId || binding.id,
321
251
  turnId: failureInfo?.turnId || null,
322
252
  cwd: agentHome,
323
253
  replyToMessageId: inbound.messageId || null,
@@ -339,6 +269,24 @@ export const codexRuntime = {
339
269
  }
340
270
  },
341
271
 
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
+
342
290
  sessionsDir: CODEX_SESSIONS_DIR,
343
291
  maxAgeMs: CODEX_MAX_AGE_MS,
344
292
  };
@@ -368,12 +368,14 @@ export function streamCodexPrompt({
368
368
  let finalText = '';
369
369
  let threadPath = null;
370
370
  const ignoredChildEventsLogged = new Set();
371
+ let eventChain = Promise.resolve();
371
372
 
372
373
  const emit = (event) => {
373
374
  if (typeof onEvent !== 'function') return;
374
- void Promise.resolve()
375
+ eventChain = eventChain
375
376
  .then(() => onEvent(event))
376
377
  .catch(() => {});
378
+ return eventChain;
377
379
  };
378
380
 
379
381
  const settle = (fn, value) => {
@@ -385,7 +387,9 @@ export function streamCodexPrompt({
385
387
  }
386
388
  pending.clear();
387
389
  try { child.kill('SIGTERM'); } catch {}
388
- fn(value);
390
+ eventChain
391
+ .catch(() => {})
392
+ .finally(() => fn(value));
389
393
  };
390
394
 
391
395
  const send = (method, params = {}) => {
@@ -447,6 +451,12 @@ export function streamCodexPrompt({
447
451
  const delta = params?.delta;
448
452
  if (typeof delta === 'string' && delta && isRootContext(params)) {
449
453
  finalText += delta;
454
+ emit({
455
+ type: 'message.delta',
456
+ sessionId: rootThreadId,
457
+ turnId: rootTurnId,
458
+ text: delta,
459
+ });
450
460
  } else if (typeof delta === 'string' && delta) {
451
461
  logIgnoredChildEvent('codex.non-root-agent-delta', params);
452
462
  }
@@ -1,6 +1,5 @@
1
1
  import { reportSubprocessFailure, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
2
- import { getGoalTaskProtocolOverlayKey } from '../_shared/goal-task-protocol.mjs';
3
- import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
2
+ import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
4
3
  import { GATEWAY_HOST, GATEWAY_PORT } from './identity.mjs';
5
4
 
6
5
  // Cheap availability probe used by the harness picker. We can't use
@@ -25,12 +24,11 @@ async function probeOpenClawGatewayHealth() {
25
24
  import { buildOpenClawSessionKey, normalizeOpenClawAgentId } from './target.mjs';
26
25
  import { askGateway, isGatewayReady, registerOpenClawChannel } from './gateway.mjs';
27
26
 
28
- // Tracks which (agentId, sessionKey, protocol overlay) combinations
29
- // already saw the standing prompt this process lifetime. OpenClaw's
30
- // gateway holds session state out of process, so we err on the side of
31
- // "inject on first observed overlay after daemon restart"; the gateway
32
- // dedupes redundant context.
33
- const standingPromptSeen = new Set();
27
+ // Tracks which (agentId, sessionKey) pairs already saw the runtime base
28
+ // instructions this process lifetime. OpenClaw's gateway holds session state
29
+ // out of process, so we err on the side of "inject on first observed
30
+ // turn after daemon restart"; the gateway dedupes redundant context.
31
+ const runtimeBaseInstructionsSeen = new Set();
34
32
  import { addInFlight, recoverInFlightEntries, removeInFlight } from './inflight.mjs';
35
33
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
36
34
  import { extname } from 'node:path';
@@ -160,16 +158,16 @@ export const openClawRuntime = {
160
158
  const agentId = normalizeOpenClawAgentId(meta.agentId || binding.id);
161
159
  const sessionId = String(meta.sessionKey || buildOpenClawSessionKey(agentId)).trim();
162
160
 
163
- // Inject the standing prompt on the first observed turn after a
164
- // daemon start for this (agent, session, protocol overlay). OpenClaw
165
- // has no separate "system prompt" parameter on the gateway, so we
166
- // prepend through the prompt body.
167
- const standingKey = `${agentId}|${sessionId}|${getGoalTaskProtocolOverlayKey({ agentId: binding.id, inbound })}`;
161
+ // Inject the runtime base instructions on the first observed turn after a
162
+ // daemon start for this (agent, session) pair. OpenClaw has no
163
+ // separate "system prompt" parameter on the gateway, so we prepend
164
+ // exactly once.
165
+ const runtimeBaseInstructionsKey = `${agentId}|${sessionId}`;
168
166
  let prompt = rawPrompt;
169
- if (!standingPromptSeen.has(standingKey)) {
170
- const standing = buildStandingPrompt({ agentId: binding.id, inbound });
171
- prompt = `${standing}\n\n---\n\n${rawPrompt}`;
172
- standingPromptSeen.add(standingKey);
167
+ if (!runtimeBaseInstructionsSeen.has(runtimeBaseInstructionsKey)) {
168
+ const baseInstructions = buildRuntimeBaseInstructions({ agentId: binding.id });
169
+ prompt = `${baseInstructions}\n\n---\n\n${rawPrompt}`;
170
+ runtimeBaseInstructionsSeen.add(runtimeBaseInstructionsKey);
173
171
  }
174
172
 
175
173
  if (inbound.messageId) {
@@ -238,6 +236,21 @@ export const openClawRuntime = {
238
236
  return entries.length;
239
237
  },
240
238
 
239
+ async reconcileAfterRestart(binding, ctx) {
240
+ const meta = binding.runtimeMeta || {};
241
+ if (typeof ctx.adapter.emitEvent === 'function') {
242
+ await ctx.adapter.emitEvent(binding, {
243
+ agent: this.name,
244
+ sessionId: String(meta.sessionKey || buildOpenClawSessionKey(meta.agentId || binding.id)).trim(),
245
+ cwd: '',
246
+ event: {
247
+ hook_event_name: 'agent.run.end',
248
+ reason: 'connector.restart.reconcile',
249
+ },
250
+ });
251
+ }
252
+ return 1;
253
+ },
241
254
  };
242
255
 
243
256
  export default openClawRuntime;
@@ -10,8 +10,7 @@
10
10
  import { existsSync } from 'node:fs';
11
11
  import { basename } from 'node:path';
12
12
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
13
- import { getGoalTaskProtocolOverlayKey } from '../_shared/goal-task-protocol.mjs';
14
- import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
13
+ import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
15
14
  import { ensureAgentHome } from '../../core/agent-home.mjs';
16
15
  import {
17
16
  createOpenCodeSession,
@@ -25,18 +24,15 @@ import {
25
24
  requireOpenCodePath,
26
25
  } from './session.mjs';
27
26
  import { buildOpenCodeInputFromInbound } from '../../core/media/inbound.mjs';
28
- import { emitWorkerEventBestEffort } from '../../core/events/worker-events.mjs';
27
+ import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
29
28
  import {
30
29
  shouldStreamRuntime,
30
+ createDeltaAggregator,
31
31
  reportSubprocessFailure,
32
32
  terminalRuntimeFailure,
33
33
  updateBindingRuntimeMeta,
34
- resolveRuntimeSessionScope,
35
- buildRuntimeSessionMetaPatch,
36
34
  } from '../../core/runtime-support.mjs';
37
35
 
38
- const standingPromptSeen = new Set();
39
-
40
36
  export const openCodeRuntime = {
41
37
  name: 'opencode',
42
38
 
@@ -51,8 +47,7 @@ export const openCodeRuntime = {
51
47
  message: text,
52
48
  opencodePath,
53
49
  agentEnv,
54
- standingPrompt: opts.standingPrompt || null,
55
- forceStandingPrompt: Boolean(opts.forceStandingPrompt),
50
+ runtimeBaseInstructions: opts.runtimeBaseInstructions || null,
56
51
  files: opts.files,
57
52
  timeoutMs: opts.timeoutMs,
58
53
  });
@@ -65,8 +60,7 @@ export const openCodeRuntime = {
65
60
  message: text,
66
61
  opencodePath,
67
62
  agentEnv,
68
- standingPrompt: opts.standingPrompt || null,
69
- forceStandingPrompt: Boolean(opts.forceStandingPrompt),
63
+ runtimeBaseInstructions: opts.runtimeBaseInstructions || null,
70
64
  files: opts.files,
71
65
  timeoutMs: opts.timeoutMs,
72
66
  onEvent: opts.onEvent,
@@ -162,39 +156,47 @@ export const openCodeRuntime = {
162
156
  // instruction so it has something to anchor on.
163
157
  message = captionText || 'Please analyze the attached image(s).';
164
158
  }
165
- const sessionScope = resolveRuntimeSessionScope(meta, inbound);
166
- const shouldRotate = sessionScope.shouldRotate;
167
- const targetSessionId = shouldRotate ? null : sessionScope.sessionId;
159
+ const shouldRotate = !meta.sessionId || meta.rotatePending;
160
+
161
+ const deltaAggregator = createDeltaAggregator({
162
+ flushDelta: async ({ text, sessionId, cwd }) => {
163
+ await emitWorkerEvent({
164
+ adapter,
165
+ binding,
166
+ agent: this.name,
167
+ sessionId: sessionId || meta.sessionId || binding.id,
168
+ cwd: cwd || agentHome,
169
+ replyToMessageId: inbound.messageId || null,
170
+ event: {
171
+ hook_event_name: 'worker.message.delta',
172
+ worker_event_name: 'worker.message.delta',
173
+ delta: text,
174
+ },
175
+ logger: ctx.logger,
176
+ });
177
+ },
178
+ });
168
179
 
169
180
  try {
170
181
  const opencodePath = requireOpenCodePath(runtimeOpenCodePath);
171
182
  const opencodeVersion = getOpenCodeRuntimeHealth(opencodePath).version || meta.opencodeVersion || null;
172
183
  const agentEnv = buildAgentRuntimeEnv({
173
184
  agentId: binding.id,
174
- sessionId: targetSessionId,
185
+ sessionId: meta.sessionId,
175
186
  hostId: binding.runtime_host_id,
176
- conversationId: inbound.conversationId,
177
- messageId: inbound.messageId,
178
- target: inbound.envelopeTarget,
179
187
  });
180
- const standingPrompt = buildStandingPrompt({ agentId: binding.id, inbound });
181
- const protocolOverlayKey = getGoalTaskProtocolOverlayKey({ agentId: binding.id, inbound });
182
- const standingPromptSeenKey = targetSessionId
183
- ? `${binding.id}|${targetSessionId}|${protocolOverlayKey}`
184
- : null;
185
- const forceStandingPrompt = Boolean(standingPromptSeenKey && !standingPromptSeen.has(standingPromptSeenKey));
188
+ const runtimeBaseInstructions = buildRuntimeBaseInstructions({ agentId: binding.id });
186
189
  const result = shouldStreamRuntime(this.name, this)
187
- ? await this.runTurnStream({ sessionId: targetSessionId, cwd: agentHome, opencodePath, agentEnv }, message, {
188
- standingPrompt,
189
- forceStandingPrompt,
190
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, {
191
+ runtimeBaseInstructions,
190
192
  files,
191
193
  onEvent: async (event) => {
192
194
  if (event?.type === 'turn.started') {
193
- emitWorkerEventBestEffort({
195
+ await emitWorkerEvent({
194
196
  adapter,
195
197
  binding,
196
198
  agent: this.name,
197
- sessionId: event.sessionId || targetSessionId || binding.id,
199
+ sessionId: event.sessionId || meta.sessionId || binding.id,
198
200
  cwd: agentHome,
199
201
  replyToMessageId: inbound.messageId || null,
200
202
  event: {
@@ -203,30 +205,31 @@ export const openCodeRuntime = {
203
205
  },
204
206
  logger: ctx.logger,
205
207
  });
208
+ } else if (event?.type === 'message.delta' && event.text) {
209
+ deltaAggregator.push(event.text, {
210
+ sessionId: event.sessionId || meta.sessionId || binding.id,
211
+ cwd: agentHome,
212
+ });
206
213
  }
207
214
  },
208
215
  })
209
- : await this.runTurn({ sessionId: targetSessionId, cwd: agentHome, opencodePath, agentEnv }, message, { files, standingPrompt, forceStandingPrompt });
216
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, { files, runtimeBaseInstructions });
210
217
 
211
- const observedSessionId = result?.sessionId || targetSessionId;
212
- if (observedSessionId) {
213
- standingPromptSeen.add(`${binding.id}|${observedSessionId}|${protocolOverlayKey}`);
214
- }
218
+ await deltaAggregator.flush();
215
219
 
216
220
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
217
- ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
218
- sessionId: result?.sessionId,
219
- path: result?.path,
220
- }),
221
+ sessionId: result?.sessionId || meta.sessionId,
221
222
  runtimePath: opencodePath,
222
223
  opencodePath,
223
224
  opencodeVersion,
225
+ rotatePending: false,
226
+ lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
224
227
  }, { status: 'connected' });
225
- emitWorkerEventBestEffort({
228
+ await emitWorkerEvent({
226
229
  adapter,
227
230
  binding: nextBinding,
228
231
  agent: this.name,
229
- sessionId: result?.sessionId || targetSessionId || binding.id,
232
+ sessionId: result?.sessionId || meta.sessionId || binding.id,
230
233
  cwd: result?.cwd || agentHome,
231
234
  replyToMessageId: inbound.messageId || null,
232
235
  event: {
@@ -237,11 +240,12 @@ export const openCodeRuntime = {
237
240
  });
238
241
  return true;
239
242
  } catch (err) {
240
- emitWorkerEventBestEffort({
243
+ await deltaAggregator.flush().catch(() => {});
244
+ await emitWorkerEvent({
241
245
  adapter,
242
246
  binding,
243
247
  agent: this.name,
244
- sessionId: targetSessionId || binding.id,
248
+ sessionId: meta.sessionId || binding.id,
245
249
  cwd: agentHome,
246
250
  replyToMessageId: inbound.messageId || null,
247
251
  event: {
@@ -267,6 +271,24 @@ export const openCodeRuntime = {
267
271
  }
268
272
  },
269
273
 
274
+ async reconcileAfterRestart(binding, ctx) {
275
+ const meta = binding.runtimeMeta || {};
276
+ await emitWorkerEvent({
277
+ adapter: ctx.adapter,
278
+ binding,
279
+ agent: this.name,
280
+ sessionId: meta.sessionId || binding.id,
281
+ cwd: ensureAgentHome(binding.id) || '',
282
+ event: {
283
+ hook_event_name: 'Stop',
284
+ worker_event_name: 'worker.turn.complete',
285
+ reason: 'connector.restart.reconcile',
286
+ },
287
+ logger: ctx.logger,
288
+ });
289
+ return 1;
290
+ },
291
+
270
292
  dataDir: OPENCODE_DATA_DIR,
271
293
  maxAgeMs: OPENCODE_MAX_AGE_MS,
272
294
  };
@@ -193,9 +193,10 @@ function buildOpenCodeRunArgs({ sessionId, message, files = [] }) {
193
193
  /**
194
194
  * Spawn `opencode run` and resolve with the final result.
195
195
  *
196
- * If `onEvent` is provided it is invoked for lifecycle events such as
197
- * `{ type: 'turn.started', sessionId }`. Token deltas are kept local to
198
- * build the final text and are not emitted to the caller.
196
+ * If `onEvent` is provided it is invoked for each parsed event during
197
+ * the turn — `{ type: 'turn.started', sessionId }`,
198
+ * `{ type: 'message.delta', sessionId, text }`, etc. so callers can
199
+ * forward typing indicators / partial text to the chat client.
199
200
  *
200
201
  * Resolves with `{ sessionId, cwd, text, durationMs }` on success.
201
202
  * Rejects with an Error whose `.info` carries `{ ok: false, kind, ... }`
@@ -208,17 +209,16 @@ export function runOpenCodePrompt({
208
209
  files = [],
209
210
  opencodePath = null,
210
211
  agentEnv = null,
211
- standingPrompt = null,
212
- forceStandingPrompt = false,
212
+ runtimeBaseInstructions = null,
213
213
  timeoutMs = Number(process.env.OPENCODE_RUN_TIMEOUT_MS || DEFAULT_OPENCODE_RUN_TIMEOUT_MS),
214
214
  onEvent,
215
215
  }) {
216
216
  // opencode has no documented `--system` flag, so we prepend the
217
- // standing prompt to the first-turn message body. Callers may force a
218
- // resumed-session injection when the selected protocol overlay changes
219
- // or this daemon process has not yet observed the session.
220
- const finalMessage = standingPrompt && (!sessionId || forceStandingPrompt)
221
- ? `${standingPrompt}\n\n---\n\n${message}`
217
+ // runtime base instructions to the first-turn message body. Resumed sessions
218
+ // (sessionId set) skip injection because the model already saw it
219
+ // on the originating turn.
220
+ const finalMessage = runtimeBaseInstructions && !sessionId
221
+ ? `${runtimeBaseInstructions}\n\n---\n\n${message}`
222
222
  : message;
223
223
  return new Promise((resolve, reject) => {
224
224
  const startedAt = Date.now();
@@ -245,19 +245,18 @@ export function runOpenCodePrompt({
245
245
  let lastError = null;
246
246
  let settled = false;
247
247
  let turnStartedEmitted = false;
248
+ let eventChain = Promise.resolve();
248
249
 
249
250
  const emit = (event) => {
250
251
  if (typeof onEvent !== 'function') return;
251
- void Promise.resolve()
252
- .then(() => onEvent(event))
253
- .catch(() => {});
252
+ eventChain = eventChain.then(() => onEvent(event)).catch(() => {});
254
253
  };
255
254
 
256
255
  const settle = (fn, value) => {
257
256
  if (settled) return;
258
257
  settled = true;
259
258
  if (timeout) clearTimeout(timeout);
260
- fn(value);
259
+ eventChain.catch(() => {}).finally(() => fn(value));
261
260
  };
262
261
 
263
262
  const handleEvent = (event) => {
@@ -273,6 +272,7 @@ export function runOpenCodePrompt({
273
272
  const delta = extractEventText(event);
274
273
  if (delta) {
275
274
  finalText += delta;
275
+ emit({ type: 'message.delta', sessionId: activeSessionId, text: delta });
276
276
  }
277
277
  }
278
278