ticlawk 0.1.17-dev.2 → 0.1.17-dev.20

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 (46) hide show
  1. package/README.md +26 -59
  2. package/bin/ticlawk.mjs +31 -301
  3. package/package.json +4 -2
  4. package/scripts/publish-dev.sh +77 -0
  5. package/src/adapters/ticlawk/api.mjs +50 -378
  6. package/src/adapters/ticlawk/credentials.mjs +1 -43
  7. package/src/adapters/ticlawk/index.mjs +61 -565
  8. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  9. package/src/cli/agent-commands.mjs +18 -715
  10. package/src/core/adapter-registry.mjs +1 -19
  11. package/src/core/agent-cli-handlers.mjs +18 -556
  12. package/src/core/agent-home.mjs +1 -81
  13. package/src/core/events/worker-events.mjs +36 -32
  14. package/src/core/http.mjs +0 -152
  15. package/src/core/profiles.mjs +0 -1
  16. package/src/core/runtime-contract.mjs +1 -0
  17. package/src/core/runtime-env.mjs +0 -8
  18. package/src/core/runtime-support.mjs +78 -130
  19. package/src/runtimes/_shared/incoming-message-prompt.mjs +232 -0
  20. package/src/runtimes/_shared/runtime-base-instructions.mjs +34 -0
  21. package/src/runtimes/claude-code/index.mjs +48 -21
  22. package/src/runtimes/claude-code/session.mjs +7 -2
  23. package/src/runtimes/codex/index.mjs +64 -116
  24. package/src/runtimes/codex/session.mjs +12 -2
  25. package/src/runtimes/openclaw/index.mjs +30 -17
  26. package/src/runtimes/opencode/index.mjs +64 -42
  27. package/src/runtimes/opencode/session.mjs +14 -14
  28. package/src/runtimes/pi/index.mjs +64 -42
  29. package/src/runtimes/pi/session.mjs +8 -11
  30. package/ticlawk.mjs +32 -5
  31. package/src/runtimes/_shared/agent-handbook.mjs +0 -45
  32. package/src/runtimes/_shared/brand.mjs +0 -2
  33. package/src/runtimes/_shared/goal-step-prompt.mjs +0 -98
  34. package/src/runtimes/_shared/goal-task-protocol.mjs +0 -50
  35. package/src/runtimes/_shared/handbook/BASICS.md +0 -27
  36. package/src/runtimes/_shared/handbook/COLLABORATION.md +0 -37
  37. package/src/runtimes/_shared/handbook/COMMUNICATION.md +0 -55
  38. package/src/runtimes/_shared/handbook/DM_SCOPE.md +0 -13
  39. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +0 -47
  40. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +0 -43
  41. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +0 -21
  42. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +0 -15
  43. package/src/runtimes/_shared/handbook/SURFACES.md +0 -41
  44. package/src/runtimes/_shared/handbook/TASK_WORKER.md +0 -14
  45. package/src/runtimes/_shared/standing-prompt.mjs +0 -171
  46. package/src/runtimes/_shared/wake-prompt.mjs +0 -268
@@ -7,10 +7,9 @@
7
7
  * lives in cwd, agent reads it via `cat MEMORY.md`.
8
8
  */
9
9
 
10
- import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { AF_HOME } from './config.mjs';
13
- import { buildAgentHandbookFiles, LEGACY_HANDBOOK_FILE_NAMES } from '../runtimes/_shared/agent-handbook.mjs';
14
13
 
15
14
  export const AF_AGENTS_DIR = join(AF_HOME, 'agents');
16
15
 
@@ -38,88 +37,9 @@ export function ensureAgentHome(agentId, { displayName } = {}) {
38
37
  if (!existsSync(memoryPath)) {
39
38
  writeFileSync(memoryPath, buildInitialMemoryMd({ displayName, home }), 'utf8');
40
39
  }
41
- writeManagedHandbookFiles(home);
42
- ensureSkillSymlinks(home);
43
40
  return home;
44
41
  }
45
42
 
46
- function writeManagedHandbookFiles(home) {
47
- removeLegacyManagedHandbookFiles(home);
48
- for (const { name, content } of buildAgentHandbookFiles()) {
49
- const path = join(home, name);
50
- try {
51
- let stat = null;
52
- try { stat = lstatSync(path); } catch { /* not present */ }
53
- if (stat && !stat.isFile()) continue;
54
- const next = `${content.trim()}\n`;
55
- if (stat) {
56
- const current = readFileSync(path, 'utf8');
57
- if (current === next) continue;
58
- }
59
- writeFileSync(path, next, { encoding: 'utf8', mode: 0o600 });
60
- } catch (err) {
61
- console.warn(`[agent-home] failed to write ${path}: ${err?.message || err}`);
62
- }
63
- }
64
- }
65
-
66
- function removeLegacyManagedHandbookFiles(home) {
67
- for (const name of LEGACY_HANDBOOK_FILE_NAMES) {
68
- const path = join(home, name);
69
- try {
70
- const stat = lstatSync(path);
71
- if (!stat.isFile()) continue;
72
- unlinkSync(path);
73
- } catch (err) {
74
- if (err?.code === 'ENOENT') continue;
75
- console.warn(`[agent-home] failed to remove legacy handbook ${path}: ${err?.message || err}`);
76
- }
77
- }
78
- }
79
-
80
- /**
81
- * Cross-runtime skill discovery: every runtime (Claude Code, Codex,
82
- * opencode, pi, openclaw) auto-discovers SKILL.md under at least one of
83
- * .claude/skills/, .codex/skills/, .opencode/skills/, .pi/skills/, or
84
- * .agents/skills/. We pin everything to a single source-of-truth
85
- * directory (.agents/skills/) and symlink the rest so a skill written
86
- * once is visible to every runtime without sync machinery.
87
- *
88
- * .pi and .opencode skip the symlink — both natively scan
89
- * .agents/skills/ in addition to their own folder.
90
- *
91
- * No migration / no recovery: if the target path already exists as a
92
- * real dir or wrong-target symlink, we leave it alone. The user (or a
93
- * future agent) resolves it manually. See cos_impl.md §一.不为错误兜底.
94
- */
95
- function ensureSkillSymlinks(home) {
96
- const realRoot = join(home, '.agents', 'skills');
97
- mkdirSync(realRoot, { recursive: true });
98
-
99
- const links = [
100
- { dir: join(home, '.claude'), name: 'skills', target: '../.agents/skills' },
101
- { dir: join(home, '.codex'), name: 'skills', target: '../.agents/skills' },
102
- // openclaw expects skills/ at the home root
103
- { dir: home, name: 'skills', target: '.agents/skills' },
104
- ];
105
-
106
- for (const { dir, name, target } of links) {
107
- mkdirSync(dir, { recursive: true });
108
- const linkPath = join(dir, name);
109
- let stat = null;
110
- try { stat = lstatSync(linkPath); } catch { /* not present */ }
111
- if (stat) continue; // path already exists — do not touch
112
- try {
113
- symlinkSync(target, linkPath, 'dir');
114
- } catch (err) {
115
- // Best-effort: an EEXIST race or platform that disallows symlinks
116
- // gets logged but doesn't fail spawn. Skills just won't be visible
117
- // for that runtime via this folder.
118
- console.warn(`[agent-home] failed to symlink ${linkPath} -> ${target}: ${err?.message || err}`);
119
- }
120
- }
121
- }
122
-
123
43
  function buildInitialMemoryMd({ displayName, home }) {
124
44
  const lines = [
125
45
  `# ${displayName || 'Agent'}`,
@@ -1,9 +1,22 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  const workerEventSeq = new Map();
4
+ const DELTA_LOG_PREVIEW_CHARS = 48;
2
5
 
3
6
  function shortId(value) {
4
7
  return value ? String(value).slice(0, 8) : null;
5
8
  }
6
9
 
10
+ function hashText(text) {
11
+ return createHash('sha1').update(String(text)).digest('hex').slice(0, 12);
12
+ }
13
+
14
+ function previewDelta(text) {
15
+ const normalized = String(text).replace(/\n/g, '\\n');
16
+ if (normalized.length <= DELTA_LOG_PREVIEW_CHARS) return normalized;
17
+ return normalized.slice(0, DELTA_LOG_PREVIEW_CHARS) + '…';
18
+ }
19
+
7
20
  function nextWorkerEventMeta({ agent, sessionId, turnId }) {
8
21
  const key = `${agent}:${sessionId || ''}:${turnId || 'session'}`;
9
22
  const seq = (workerEventSeq.get(key) || 0) + 1;
@@ -24,16 +37,6 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
24
37
  if (!sessionId || typeof adapter.emitEvent !== 'function') return;
25
38
  const eventName = event?.worker_event_name || event?.hook_event_name || 'unknown';
26
39
  const logicalTurnId = replyToMessageId || turnId || event?.turn_id || null;
27
- if (eventName === 'worker.message.delta') {
28
- logger?.debugLog?.('events', 'delta.drop', {
29
- bindingId: binding.id,
30
- agent,
31
- sessionId: shortId(sessionId),
32
- turnId: shortId(logicalTurnId),
33
- chars: typeof event?.delta === 'string' ? event.delta.length : null,
34
- });
35
- return;
36
- }
37
40
  const { key, seq, originTs } = nextWorkerEventMeta({ agent, sessionId, turnId: logicalTurnId });
38
41
  const enrichedEvent = {
39
42
  ...event,
@@ -42,14 +45,29 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
42
45
  event_seq: seq,
43
46
  origin_ts: originTs,
44
47
  };
45
- logger?.debugLog?.('events', 'event.recv', {
46
- bindingId: binding.id,
47
- agent,
48
- sessionId: shortId(sessionId),
49
- turnId: shortId(logicalTurnId),
50
- seq,
51
- eventName,
52
- });
48
+ if (typeof event?.delta === 'string' && event.delta) {
49
+ enrichedEvent.delta_chars = event.delta.length;
50
+ enrichedEvent.delta_hash = hashText(event.delta);
51
+ logger?.debugLog?.('events', 'delta.recv', {
52
+ bindingId: binding.id,
53
+ agent,
54
+ sessionId: shortId(sessionId),
55
+ turnId: shortId(logicalTurnId),
56
+ seq,
57
+ chars: event.delta.length,
58
+ hash: enrichedEvent.delta_hash,
59
+ preview: previewDelta(event.delta),
60
+ });
61
+ } else {
62
+ logger?.debugLog?.('events', 'event.recv', {
63
+ bindingId: binding.id,
64
+ agent,
65
+ sessionId: shortId(sessionId),
66
+ turnId: shortId(logicalTurnId),
67
+ seq,
68
+ eventName,
69
+ });
70
+ }
53
71
  await adapter.emitEvent(binding, {
54
72
  agent,
55
73
  sessionId,
@@ -60,17 +78,3 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
60
78
  clearWorkerEventMeta(key);
61
79
  }
62
80
  }
63
-
64
- export function emitWorkerEventBestEffort(args) {
65
- void emitWorkerEvent(args).catch((err) => {
66
- const event = args?.event || {};
67
- const eventName = event.worker_event_name || event.hook_event_name || 'unknown';
68
- args?.logger?.debugError?.('events', 'event.best-effort-failed', {
69
- bindingId: args?.binding?.id || null,
70
- agent: args?.agent || null,
71
- sessionId: shortId(args?.sessionId),
72
- eventName,
73
- error: err?.message || 'unknown error',
74
- });
75
- });
76
- }
package/src/core/http.mjs CHANGED
@@ -3,25 +3,6 @@ import {
3
3
  handleAttachmentUpload,
4
4
  handleAttachmentView,
5
5
  handleGroupCreate,
6
- handleWorkstreamCharterGet,
7
- handleWorkstreamCharterSet,
8
- handleWorkstreamCreate,
9
- handleWorkstreamDelete,
10
- handleWorkstreamList,
11
- handleAgentList,
12
- handleAgentCreate,
13
- handleAgentDelete,
14
- handleWorkstreamDashboardSet,
15
- handleWorkstreamDashboardGet,
16
- handleCredentialRequest,
17
- handleBriefingPublish,
18
- handleBriefingGet,
19
- handleServiceCreate,
20
- handleServiceUpdate,
21
- handleServiceDelete,
22
- handleServiceList,
23
- handleServiceInfo,
24
- handleServiceCall,
25
6
  handleGroupMembers,
26
7
  handleGroupMembersAdd,
27
8
  handleGroupMembersRemove,
@@ -39,11 +20,6 @@ import {
39
20
  handleReminderSchedule,
40
21
  handleReminderSnooze,
41
22
  handleReminderUpdate,
42
- handleGoalChanged,
43
- handleGoalReport,
44
- handleApprovalRequest,
45
- handleApprovalResolve,
46
- handleApprovalList,
47
23
  handleServerInfo,
48
24
  handleTaskClaim,
49
25
  handleTaskCreate,
@@ -149,34 +125,6 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
149
125
  const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
150
126
  return writeJson(res, r.status, r.body);
151
127
  }
152
- if (urlNoQuery === '/agent/goal/report' && method === 'POST') {
153
- const body = await readJsonBody(req);
154
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
155
- const r = await handleGoalReport(req, body, cliCtx);
156
- return writeJson(res, r.status, r.body);
157
- }
158
- if (urlNoQuery === '/agent/goal/changed' && method === 'POST') {
159
- const body = await readJsonBody(req);
160
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
161
- const r = await handleGoalChanged(req, body, cliCtx);
162
- return writeJson(res, r.status, r.body);
163
- }
164
- if (urlNoQuery === '/agent/approval/request' && method === 'POST') {
165
- const body = await readJsonBody(req);
166
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
167
- const r = await handleApprovalRequest(req, body, cliCtx);
168
- return writeJson(res, r.status, r.body);
169
- }
170
- if (urlNoQuery === '/agent/approval/resolve' && method === 'POST') {
171
- const body = await readJsonBody(req);
172
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
173
- const r = await handleApprovalResolve(req, body, cliCtx);
174
- return writeJson(res, r.status, r.body);
175
- }
176
- if (urlNoQuery === '/agent/approval/list' && method === 'GET') {
177
- const r = await handleApprovalList(req, parseQuery(req.url || ''), cliCtx);
178
- return writeJson(res, r.status, r.body);
179
- }
180
128
  if (urlNoQuery === '/agent/message/react' && method === 'POST') {
181
129
  const body = await readJsonBody(req);
182
130
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
@@ -275,106 +223,6 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
275
223
  const r = await handleServerInfo(req, parseQuery(req.url || ''), cliCtx);
276
224
  return writeJson(res, r.status, r.body);
277
225
  }
278
- if (urlNoQuery === '/agent/workstream/charter/get' && method === 'GET') {
279
- const r = await handleWorkstreamCharterGet(req, parseQuery(req.url || ''), cliCtx);
280
- return writeJson(res, r.status, r.body);
281
- }
282
- if (urlNoQuery === '/agent/workstream/charter/set' && method === 'POST') {
283
- const body = await readJsonBody(req);
284
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
285
- const r = await handleWorkstreamCharterSet(req, body, cliCtx);
286
- return writeJson(res, r.status, r.body);
287
- }
288
- if (urlNoQuery === '/agent/workstream/create' && method === 'POST') {
289
- const body = await readJsonBody(req);
290
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
291
- const r = await handleWorkstreamCreate(req, body, cliCtx);
292
- return writeJson(res, r.status, r.body);
293
- }
294
- if (urlNoQuery === '/agent/workstream/delete' && method === 'POST') {
295
- const body = await readJsonBody(req);
296
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
297
- const r = await handleWorkstreamDelete(req, body, cliCtx);
298
- return writeJson(res, r.status, r.body);
299
- }
300
- if (urlNoQuery === '/agent/workstream/list' && method === 'GET') {
301
- const r = await handleWorkstreamList(req, parseQuery(req.url || ''), cliCtx);
302
- return writeJson(res, r.status, r.body);
303
- }
304
- if (urlNoQuery === '/agent/agent/list' && method === 'GET') {
305
- const r = await handleAgentList(req, parseQuery(req.url || ''), cliCtx);
306
- return writeJson(res, r.status, r.body);
307
- }
308
- if (urlNoQuery === '/agent/agent/create' && method === 'POST') {
309
- const body = await readJsonBody(req);
310
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
311
- const r = await handleAgentCreate(req, body, cliCtx);
312
- return writeJson(res, r.status, r.body);
313
- }
314
- if (urlNoQuery === '/agent/agent/delete' && method === 'POST') {
315
- const body = await readJsonBody(req);
316
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
317
- const r = await handleAgentDelete(req, body, cliCtx);
318
- return writeJson(res, r.status, r.body);
319
- }
320
- if (urlNoQuery === '/agent/dashboard/set' && method === 'POST') {
321
- const body = await readJsonBody(req);
322
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
323
- const r = await handleWorkstreamDashboardSet(req, body, cliCtx);
324
- return writeJson(res, r.status, r.body);
325
- }
326
- if (urlNoQuery === '/agent/dashboard/get' && method === 'GET') {
327
- const r = await handleWorkstreamDashboardGet(req, parseQuery(req.url || ''), cliCtx);
328
- return writeJson(res, r.status, r.body);
329
- }
330
- if (urlNoQuery === '/agent/briefing/publish' && method === 'POST') {
331
- const body = await readJsonBody(req);
332
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
333
- const r = await handleBriefingPublish(req, body, cliCtx);
334
- return writeJson(res, r.status, r.body);
335
- }
336
- if (urlNoQuery === '/agent/briefing/get' && method === 'GET') {
337
- const r = await handleBriefingGet(req, parseQuery(req.url || ''), cliCtx);
338
- return writeJson(res, r.status, r.body);
339
- }
340
- if (urlNoQuery === '/agent/credential/request' && method === 'POST') {
341
- const body = await readJsonBody(req);
342
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
343
- const r = await handleCredentialRequest(req, body, cliCtx);
344
- return writeJson(res, r.status, r.body);
345
- }
346
- if (urlNoQuery === '/agent/service/create' && method === 'POST') {
347
- const body = await readJsonBody(req);
348
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
349
- const r = await handleServiceCreate(req, body, cliCtx);
350
- return writeJson(res, r.status, r.body);
351
- }
352
- if (urlNoQuery === '/agent/service/update' && method === 'POST') {
353
- const body = await readJsonBody(req);
354
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
355
- const r = await handleServiceUpdate(req, body, cliCtx);
356
- return writeJson(res, r.status, r.body);
357
- }
358
- if (urlNoQuery === '/agent/service/delete' && method === 'POST') {
359
- const body = await readJsonBody(req);
360
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
361
- const r = await handleServiceDelete(req, body, cliCtx);
362
- return writeJson(res, r.status, r.body);
363
- }
364
- if (urlNoQuery === '/agent/service/list' && method === 'GET') {
365
- const r = await handleServiceList(req, parseQuery(req.url || ''), cliCtx);
366
- return writeJson(res, r.status, r.body);
367
- }
368
- if (urlNoQuery === '/agent/service/info' && method === 'GET') {
369
- const r = await handleServiceInfo(req, parseQuery(req.url || ''), cliCtx);
370
- return writeJson(res, r.status, r.body);
371
- }
372
- if (urlNoQuery === '/agent/service/call' && method === 'POST') {
373
- const body = await readJsonBody(req);
374
- if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
375
- const r = await handleServiceCall(req, body, cliCtx);
376
- return writeJson(res, r.status, r.body);
377
- }
378
226
 
379
227
  writeJson(res, 404, { error: 'not found' });
380
228
  } catch (err) {
@@ -92,7 +92,6 @@ export function saveProfile({ adapter, userId, config = {}, meta = {} }) {
92
92
  mkdirSync(dir, { recursive: true });
93
93
  const currentConfig = readProfileConfig(adapter, userId);
94
94
  const nextConfig = { ...currentConfig, ...config };
95
- delete nextConfig.TICLAWK_SETUP_CODE;
96
95
  writeDotenvAtomic(getProfileConfigPath(adapter, userId), nextConfig);
97
96
 
98
97
  const currentMeta = readProfileMeta(adapter, userId) || {};
@@ -55,6 +55,7 @@ import { normalizeServiceType } from './runtime-registry.mjs';
55
55
  * @property {(inbound: any, ctx: RuntimeDeliveryContext) => Promise<boolean>} deliverTurn
56
56
  * @property {(binding: any, ctx: { adapter: any, logger: any }) => (Promise<void>|void)} [onBindingUpdated]
57
57
  * @property {(ctx: { adapter: any, getBinding: (bindingId: string) => any }) => (Promise<number>|number)} [recoverInFlight]
58
+ * @property {(binding: any, ctx: { adapter: any, logger: any }) => (Promise<number>|number)} [reconcileAfterRestart]
58
59
  */
59
60
 
60
61
  /**
@@ -15,8 +15,6 @@
15
15
  const STRIPPED_KEYS = new Set([
16
16
  'TICLAWK_CONNECTOR_API_KEY',
17
17
  'TICLAWK_CONNECTOR_WS_URL',
18
- 'TICLAWK_CREDENTIAL_NAMES',
19
- 'TICLAWK_SETUP_CODE',
20
18
  ]);
21
19
 
22
20
  export function buildRuntimeEnv(extra = {}) {
@@ -36,17 +34,11 @@ export function buildAgentRuntimeEnv({
36
34
  sessionId,
37
35
  hostId,
38
36
  daemonUrl,
39
- conversationId,
40
- messageId,
41
- target,
42
37
  } = {}) {
43
38
  const out = {};
44
39
  if (agentId) out.TICLAWK_RUNTIME_AGENT_ID = String(agentId);
45
40
  if (sessionId) out.TICLAWK_RUNTIME_SESSION_ID = String(sessionId);
46
41
  if (hostId) out.TICLAWK_RUNTIME_HOST_ID = String(hostId);
47
- if (conversationId) out.TICLAWK_RUNTIME_CONVERSATION_ID = String(conversationId);
48
- if (messageId) out.TICLAWK_RUNTIME_MESSAGE_ID = String(messageId);
49
- if (target) out.TICLAWK_RUNTIME_TARGET = String(target);
50
42
  out.TICLAWK_RUNTIME_DAEMON_URL = daemonUrl || 'http://127.0.0.1:8741';
51
43
  return out;
52
44
  }
@@ -1,8 +1,8 @@
1
1
  import { getStreamingMode } from './config.mjs';
2
2
  import { getAgentHome } from './agent-home.mjs';
3
- import { debugError } from './logger.mjs';
4
3
  const ERROR_MAX_CHARS = 500;
5
- const MAX_SCOPED_RUNTIME_SESSIONS = 50;
4
+ const DEFAULT_DELTA_FLUSH_MS = 250;
5
+ const DEFAULT_DELTA_FLUSH_CHARS = 64;
6
6
 
7
7
  function truncateError(text) {
8
8
  if (!text) return null;
@@ -74,21 +74,12 @@ export async function reportSubprocessFailure({ adapter, binding, inbound, runti
74
74
  // skips fan-out to member-role agents, breaking what would otherwise
75
75
  // be a failure→fan-out→failure cascade when several agents share a
76
76
  // broken runtime.
77
- try {
78
- await adapter.postAgentReply(binding, {
79
- conversationId: inbound?.conversationId || null,
80
- text,
81
- replyToMessageId: inbound?.messageId || null,
82
- visibility: 'admin',
83
- });
84
- } catch (err) {
85
- debugError('runtime', 'failure-notice.failed', {
86
- agentId: binding?.id || null,
87
- runtimeName,
88
- messageId: inbound?.messageId || null,
89
- error: err?.message || 'unknown error',
90
- });
91
- }
77
+ await adapter.postAgentReply(binding, {
78
+ conversationId: inbound?.conversationId || null,
79
+ text,
80
+ replyToMessageId: inbound?.messageId || null,
81
+ visibility: 'admin',
82
+ });
92
83
  }
93
84
 
94
85
  export function terminalRuntimeFailure(reason = 'runtime failure') {
@@ -114,123 +105,80 @@ export async function updateBindingRuntimeMeta(ctx, binding, runtimeMetaPatch, e
114
105
  });
115
106
  }
116
107
 
117
- function readScopedSessionKey(inbound = {}) {
118
- const conversationId = String(inbound?.conversationId || '').trim();
119
- if (!conversationId) return '';
120
- const threadRoot = String(inbound?.raw?.thread_root_message_id || '').trim();
121
- return threadRoot ? `${conversationId}:thread:${threadRoot}` : conversationId;
122
- }
123
-
124
- function normalizeScopedRuntimeSessions(value) {
125
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
126
- const out = {};
127
- for (const [key, session] of Object.entries(value)) {
128
- if (!key || !session || typeof session !== 'object' || Array.isArray(session)) continue;
129
- const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : '';
130
- if (!sessionId) continue;
131
- out[key] = {
132
- sessionId,
133
- path: typeof session.path === 'string' ? session.path : null,
134
- lastRotatedAt: typeof session.lastRotatedAt === 'string' ? session.lastRotatedAt : null,
135
- updatedAt: typeof session.updatedAt === 'string' ? session.updatedAt : null,
136
- };
137
- }
138
- return out;
139
- }
140
-
141
- function pruneScopedRuntimeSessions(sessions) {
142
- const entries = Object.entries(sessions);
143
- if (entries.length <= MAX_SCOPED_RUNTIME_SESSIONS) return sessions;
144
- return Object.fromEntries(entries
145
- .sort(([, a], [, b]) => {
146
- const aTs = Date.parse(a?.updatedAt || a?.lastRotatedAt || '') || 0;
147
- const bTs = Date.parse(b?.updatedAt || b?.lastRotatedAt || '') || 0;
148
- return bTs - aTs;
149
- })
150
- .slice(0, MAX_SCOPED_RUNTIME_SESSIONS));
151
- }
152
-
153
- // The chat lane and the goal-FSM lane keep separate scoped session maps so a
154
- // transition turn never resumes a user-chat runtime session (their per-step
155
- // prompts and context differ). Lane is carried on the inbound; default chat.
156
- function laneSessionsField(lane) {
157
- return lane === 'goal' ? 'goalSessions' : 'chatSessions';
158
- }
159
-
160
- export function resolveRuntimeSessionScope(meta = {}, inbound = {}) {
161
- const lane = inbound?.lane === 'goal' ? 'goal' : 'chat';
162
- const field = laneSessionsField(lane);
163
- const key = readScopedSessionKey(inbound);
164
- if (!key) {
165
- return {
166
- key: '',
167
- lane,
168
- field,
169
- sessions: {},
170
- sessionId: meta.sessionId || null,
171
- path: meta.path || null,
172
- lastRotatedAt: meta.lastRotatedAt || null,
173
- shouldRotate: !meta.sessionId || Boolean(meta.rotatePending),
174
- };
175
- }
176
-
177
- const sessions = meta.rotatePending ? {} : normalizeScopedRuntimeSessions(meta[field]);
178
- const scoped = sessions[key] || {};
179
- return {
180
- key,
181
- lane,
182
- field,
183
- sessions,
184
- sessionId: scoped.sessionId || null,
185
- path: scoped.path || null,
186
- lastRotatedAt: scoped.lastRotatedAt || null,
187
- shouldRotate: !scoped.sessionId || Boolean(meta.rotatePending),
108
+ export function createDeltaAggregator({
109
+ flushDelta,
110
+ flushMs = DEFAULT_DELTA_FLUSH_MS,
111
+ flushChars = DEFAULT_DELTA_FLUSH_CHARS,
112
+ }) {
113
+ let buffer = '';
114
+ let pendingMeta = null;
115
+ let timer = null;
116
+ let flushChain = Promise.resolve();
117
+
118
+ const clearTimer = () => {
119
+ if (!timer) return;
120
+ clearTimeout(timer);
121
+ timer = null;
188
122
  };
189
- }
190
123
 
191
- export function buildRuntimeSessionMetaPatch(meta = {}, scope = {}, result = {}) {
192
- const now = new Date().toISOString();
193
- const scoped = Boolean(scope.key);
194
- const lane = scope.lane === 'goal' ? 'goal' : 'chat';
195
- const field = scope.field || laneSessionsField(lane);
196
- const sessionId = result?.sessionId || scope.sessionId || (scoped ? null : meta.sessionId || null);
197
- const path = result?.path || scope.path || (scoped ? null : meta.path || null);
198
- const lastRotatedAt = scope.shouldRotate
199
- ? now
200
- : (scope.lastRotatedAt || meta.lastRotatedAt || now);
201
-
202
- if (!scoped) {
203
- return {
204
- sessionId,
205
- ...(path !== undefined ? { path } : {}),
206
- rotatePending: false,
207
- lastRotatedAt,
208
- };
209
- }
124
+ const startFlush = () => {
125
+ if (!buffer || typeof flushDelta !== 'function') return flushChain;
126
+ const text = buffer;
127
+ const meta = pendingMeta || {};
128
+ buffer = '';
129
+ pendingMeta = null;
130
+ clearTimer();
131
+ flushChain = flushChain
132
+ .catch(() => {})
133
+ .then(() => flushDelta({ text, ...meta }));
134
+ return flushChain;
135
+ };
210
136
 
211
- const sessions = {
212
- ...(scope.sessions || {}),
137
+ const scheduleFlush = () => {
138
+ if (!buffer || timer) return;
139
+ timer = setTimeout(() => {
140
+ timer = null;
141
+ void startFlush();
142
+ }, flushMs);
143
+ timer.unref?.();
213
144
  };
214
- if (sessionId) {
215
- sessions[scope.key] = {
216
- sessionId,
217
- path,
218
- lastRotatedAt,
219
- updatedAt: now,
220
- };
221
- }
222
145
 
223
- const patch = {
224
- [field]: pruneScopedRuntimeSessions(sessions),
225
- rotatePending: false,
226
- lastRotatedAt,
146
+ return {
147
+ push(text, meta = {}) {
148
+ const normalized = typeof text === 'string' ? text : '';
149
+ if (!normalized) return;
150
+
151
+ const nextMeta = {
152
+ sessionId: meta.sessionId || null,
153
+ turnId: meta.turnId || null,
154
+ cwd: meta.cwd || '',
155
+ };
156
+
157
+ const sameContext = !pendingMeta
158
+ || (
159
+ pendingMeta.sessionId === nextMeta.sessionId
160
+ && pendingMeta.turnId === nextMeta.turnId
161
+ && pendingMeta.cwd === nextMeta.cwd
162
+ );
163
+
164
+ if (!sameContext && buffer) {
165
+ void startFlush();
166
+ }
167
+
168
+ pendingMeta = nextMeta;
169
+ buffer += normalized;
170
+
171
+ if (buffer.length >= flushChars) {
172
+ void startFlush();
173
+ return;
174
+ }
175
+
176
+ scheduleFlush();
177
+ },
178
+
179
+ async flush() {
180
+ clearTimer();
181
+ await startFlush();
182
+ },
227
183
  };
228
- // The flat sessionId/path is the chat lane's "last used" mirror, read only
229
- // by non-scoped resolution and display. The goal lane owns its own map and
230
- // must not clobber that mirror.
231
- if (lane === 'chat') {
232
- patch.sessionId = sessionId;
233
- patch.path = path;
234
- }
235
- return patch;
236
184
  }