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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -3
  2. package/bin/ticlawk.mjs +255 -21
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +350 -50
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +248 -130
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +715 -18
  9. package/src/core/agent-cli-handlers.mjs +556 -18
  10. package/src/core/agent-home.mjs +81 -1
  11. package/src/core/argv.mjs +11 -1
  12. package/src/core/events/worker-events.mjs +32 -36
  13. package/src/core/http.mjs +152 -0
  14. package/src/core/runtime-contract.mjs +0 -1
  15. package/src/core/runtime-env.mjs +7 -0
  16. package/src/core/runtime-support.mjs +130 -78
  17. package/src/runtimes/_shared/agent-handbook.mjs +45 -0
  18. package/src/runtimes/_shared/brand.mjs +2 -0
  19. package/src/runtimes/_shared/goal-step-prompt.mjs +98 -0
  20. package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
  21. package/src/runtimes/_shared/handbook/BASICS.md +27 -0
  22. package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
  23. package/src/runtimes/_shared/handbook/COMMUNICATION.md +55 -0
  24. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  25. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +47 -0
  26. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
  27. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
  28. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
  29. package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
  30. package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
  31. package/src/runtimes/_shared/standing-prompt.mjs +124 -279
  32. package/src/runtimes/_shared/wake-prompt.mjs +268 -0
  33. package/src/runtimes/claude-code/index.mjs +19 -46
  34. package/src/runtimes/claude-code/session.mjs +2 -7
  35. package/src/runtimes/codex/index.mjs +115 -63
  36. package/src/runtimes/codex/session.mjs +2 -12
  37. package/src/runtimes/openclaw/index.mjs +11 -24
  38. package/src/runtimes/opencode/index.mjs +38 -60
  39. package/src/runtimes/opencode/session.mjs +12 -12
  40. package/src/runtimes/pi/index.mjs +38 -60
  41. package/src/runtimes/pi/session.mjs +9 -6
  42. package/ticlawk.mjs +0 -30
@@ -7,9 +7,10 @@
7
7
  * lives in cwd, agent reads it via `cat MEMORY.md`.
8
8
  */
9
9
 
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, 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';
13
14
 
14
15
  export const AF_AGENTS_DIR = join(AF_HOME, 'agents');
15
16
 
@@ -37,9 +38,88 @@ export function ensureAgentHome(agentId, { displayName } = {}) {
37
38
  if (!existsSync(memoryPath)) {
38
39
  writeFileSync(memoryPath, buildInitialMemoryMd({ displayName, home }), 'utf8');
39
40
  }
41
+ writeManagedHandbookFiles(home);
42
+ ensureSkillSymlinks(home);
40
43
  return home;
41
44
  }
42
45
 
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
+
43
123
  function buildInitialMemoryMd({ displayName, home }) {
44
124
  const lines = [
45
125
  `# ${displayName || 'Agent'}`,
package/src/core/argv.mjs CHANGED
@@ -29,7 +29,17 @@ export function parseOptionArgs(argv = []) {
29
29
  const value = inlineValue !== undefined
30
30
  ? inlineValue
31
31
  : argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
32
- args[rawKey] = value;
32
+ // Repeated flags collect into an array so `--attach a --attach b`
33
+ // surfaces as ['a','b'] to callers that expect repeatable input
34
+ // (--attach on message send, --member on group create, etc).
35
+ // First occurrence stays scalar so single-value callers don't
36
+ // have to learn array-or-string.
37
+ if (Object.prototype.hasOwnProperty.call(args, rawKey)) {
38
+ const existing = args[rawKey];
39
+ args[rawKey] = Array.isArray(existing) ? [...existing, value] : [existing, value];
40
+ } else {
41
+ args[rawKey] = value;
42
+ }
33
43
  continue;
34
44
  }
35
45
  args._.push(arg);
@@ -1,22 +1,9 @@
1
- import { createHash } from 'node:crypto';
2
-
3
1
  const workerEventSeq = new Map();
4
- const DELTA_LOG_PREVIEW_CHARS = 48;
5
2
 
6
3
  function shortId(value) {
7
4
  return value ? String(value).slice(0, 8) : null;
8
5
  }
9
6
 
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
-
20
7
  function nextWorkerEventMeta({ agent, sessionId, turnId }) {
21
8
  const key = `${agent}:${sessionId || ''}:${turnId || 'session'}`;
22
9
  const seq = (workerEventSeq.get(key) || 0) + 1;
@@ -37,6 +24,16 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
37
24
  if (!sessionId || typeof adapter.emitEvent !== 'function') return;
38
25
  const eventName = event?.worker_event_name || event?.hook_event_name || 'unknown';
39
26
  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
+ }
40
37
  const { key, seq, originTs } = nextWorkerEventMeta({ agent, sessionId, turnId: logicalTurnId });
41
38
  const enrichedEvent = {
42
39
  ...event,
@@ -45,29 +42,14 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
45
42
  event_seq: seq,
46
43
  origin_ts: originTs,
47
44
  };
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
- }
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
+ });
71
53
  await adapter.emitEvent(binding, {
72
54
  agent,
73
55
  sessionId,
@@ -78,3 +60,17 @@ export async function emitWorkerEvent({ adapter, binding, agent, sessionId, cwd
78
60
  clearWorkerEventMeta(key);
79
61
  }
80
62
  }
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,6 +3,25 @@ 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,
6
25
  handleGroupMembers,
7
26
  handleGroupMembersAdd,
8
27
  handleGroupMembersRemove,
@@ -20,6 +39,11 @@ import {
20
39
  handleReminderSchedule,
21
40
  handleReminderSnooze,
22
41
  handleReminderUpdate,
42
+ handleGoalChanged,
43
+ handleGoalReport,
44
+ handleApprovalRequest,
45
+ handleApprovalResolve,
46
+ handleApprovalList,
23
47
  handleServerInfo,
24
48
  handleTaskClaim,
25
49
  handleTaskCreate,
@@ -125,6 +149,34 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
125
149
  const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
126
150
  return writeJson(res, r.status, r.body);
127
151
  }
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
+ }
128
180
  if (urlNoQuery === '/agent/message/react' && method === 'POST') {
129
181
  const body = await readJsonBody(req);
130
182
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
@@ -223,6 +275,106 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
223
275
  const r = await handleServerInfo(req, parseQuery(req.url || ''), cliCtx);
224
276
  return writeJson(res, r.status, r.body);
225
277
  }
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
+ }
226
378
 
227
379
  writeJson(res, 404, { error: 'not found' });
228
380
  } catch (err) {
@@ -55,7 +55,6 @@ 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]
59
58
  */
60
59
 
61
60
  /**
@@ -15,6 +15,7 @@
15
15
  const STRIPPED_KEYS = new Set([
16
16
  'TICLAWK_CONNECTOR_API_KEY',
17
17
  'TICLAWK_CONNECTOR_WS_URL',
18
+ 'TICLAWK_CREDENTIAL_NAMES',
18
19
  'TICLAWK_SETUP_CODE',
19
20
  ]);
20
21
 
@@ -35,11 +36,17 @@ export function buildAgentRuntimeEnv({
35
36
  sessionId,
36
37
  hostId,
37
38
  daemonUrl,
39
+ conversationId,
40
+ messageId,
41
+ target,
38
42
  } = {}) {
39
43
  const out = {};
40
44
  if (agentId) out.TICLAWK_RUNTIME_AGENT_ID = String(agentId);
41
45
  if (sessionId) out.TICLAWK_RUNTIME_SESSION_ID = String(sessionId);
42
46
  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);
43
50
  out.TICLAWK_RUNTIME_DAEMON_URL = daemonUrl || 'http://127.0.0.1:8741';
44
51
  return out;
45
52
  }
@@ -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';
3
4
  const ERROR_MAX_CHARS = 500;
4
- const DEFAULT_DELTA_FLUSH_MS = 250;
5
- const DEFAULT_DELTA_FLUSH_CHARS = 64;
5
+ const MAX_SCOPED_RUNTIME_SESSIONS = 50;
6
6
 
7
7
  function truncateError(text) {
8
8
  if (!text) return null;
@@ -74,12 +74,21 @@ 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
- await adapter.postAgentReply(binding, {
78
- conversationId: inbound?.conversationId || null,
79
- text,
80
- replyToMessageId: inbound?.messageId || null,
81
- visibility: 'admin',
82
- });
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
+ }
83
92
  }
84
93
 
85
94
  export function terminalRuntimeFailure(reason = 'runtime failure') {
@@ -105,80 +114,123 @@ export async function updateBindingRuntimeMeta(ctx, binding, runtimeMetaPatch, e
105
114
  });
106
115
  }
107
116
 
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;
122
- };
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
123
 
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
- };
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
+ }
136
140
 
137
- const scheduleFlush = () => {
138
- if (!buffer || timer) return;
139
- timer = setTimeout(() => {
140
- timer = null;
141
- void startFlush();
142
- }, flushMs);
143
- timer.unref?.();
144
- };
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
+ }
145
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] || {};
146
179
  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
- },
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),
188
+ };
189
+ }
178
190
 
179
- async flush() {
180
- clearTimer();
181
- await startFlush();
182
- },
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
+ }
210
+
211
+ const sessions = {
212
+ ...(scope.sessions || {}),
183
213
  };
214
+ if (sessionId) {
215
+ sessions[scope.key] = {
216
+ sessionId,
217
+ path,
218
+ lastRotatedAt,
219
+ updatedAt: now,
220
+ };
221
+ }
222
+
223
+ const patch = {
224
+ [field]: pruneScopedRuntimeSessions(sessions),
225
+ rotatePending: false,
226
+ lastRotatedAt,
227
+ };
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;
184
236
  }