@bububuger/spanory 0.1.14 → 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.
package/dist/index.js CHANGED
@@ -12,9 +12,11 @@ import { createCodexProxyServer } from './runtime/codex/proxy.js';
12
12
  import { openclawAdapter } from './runtime/openclaw/adapter.js';
13
13
  import { compileOtlp, parseHeaders, sendOtlp } from './otlp.js';
14
14
  import { loadUserEnv } from './env.js';
15
+ import { waitForFileMtimeToSettle } from './runtime/shared/file-settle.js';
15
16
  import { langfuseBackendAdapter } from '../../backend-langfuse/dist/index.js';
16
17
  import { evaluateRules, loadAlertRules, sendAlertWebhook } from './alert/evaluate.js';
17
18
  import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
19
+ import { loadIssueState, parsePendingTodoItems, resolveIssueStatePath, resolveTodoPath, saveIssueState, setIssueStatus, syncIssueState, } from './issue/state.js';
18
20
  const runtimeAdapters = {
19
21
  'claude-code': claudeCodeAdapter,
20
22
  codex: codexAdapter,
@@ -242,6 +244,23 @@ async function runHookMode(options) {
242
244
  if (!resolvedContext) {
243
245
  throw new Error('cannot resolve runtime context from hook payload; require session_id (or thread_id)');
244
246
  }
247
+ if (runtimeName === 'codex') {
248
+ const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
249
+ const transcriptPath = resolvedContext.transcriptPath
250
+ ?? (await listCodexSessions(runtimeHome)).find((session) => session.sessionId === resolvedContext.sessionId)?.transcriptPath;
251
+ if (transcriptPath) {
252
+ const settle = await waitForFileMtimeToSettle({
253
+ filePath: transcriptPath,
254
+ stableWindowMs: 350,
255
+ timeoutMs: 2500,
256
+ pollMs: 120,
257
+ });
258
+ resolvedContext.transcriptPath = transcriptPath;
259
+ if (!settle.settled) {
260
+ console.log(`hook=settle-timeout sessionId=${resolvedContext.sessionId} waitedMs=${settle.waitedMs}`);
261
+ }
262
+ }
263
+ }
245
264
  await runContextExportMode({
246
265
  runtimeName,
247
266
  context: resolvedContext,
@@ -821,11 +840,23 @@ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile })
821
840
  + ` echo "skip=empty-payload source=codex-notify args=$#" >> "${logFile}"\n`
822
841
  + ' exit 0\n'
823
842
  + 'fi\n'
843
+ + 'payload_file="$(mktemp "${TMPDIR:-/tmp}/spanory-codex-notify.XXXXXX")"\n'
844
+ + 'printf \'%s\' "$payload" > "$payload_file"\n'
824
845
  + `echo "$payload" | "${spanoryBin}" runtime codex hook \\\n`
825
846
  + ' --last-turn-only \\\n'
826
847
  + ` --runtime-home "${codexHome}" \\\n`
827
848
  + ` --export-json-dir "${exportDir}" \\\n`
828
- + ` >> "${logFile}" 2>&1 || true\n`;
849
+ + ` >> "${logFile}" 2>&1 || true\n`
850
+ + '(\n'
851
+ + ' sleep 2\n'
852
+ + ` cat "$payload_file" | "${spanoryBin}" runtime codex hook \\\n`
853
+ + ' --last-turn-only \\\n'
854
+ + ' --force \\\n'
855
+ + ` --runtime-home "${codexHome}" \\\n`
856
+ + ` --export-json-dir "${exportDir}" \\\n`
857
+ + ` >> "${logFile}" 2>&1 || true\n`
858
+ + ' rm -f "$payload_file"\n'
859
+ + ') >/dev/null 2>&1 &\n';
829
860
  }
830
861
  async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
831
862
  const codexHome = path.join(homeRoot, '.codex');
@@ -1613,6 +1644,63 @@ alert
1613
1644
  process.exitCode = 2;
1614
1645
  }
1615
1646
  });
1647
+ const issue = program.command('issue').description('Manage local issue status for automation巡检');
1648
+ issue
1649
+ .command('sync')
1650
+ .description('Sync pending items from todo.md into issue state file')
1651
+ .option('--todo-file <path>', 'Path to todo markdown file (default: ./todo.md)')
1652
+ .option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
1653
+ .action(async (options) => {
1654
+ const todoFile = resolveTodoPath(options.todoFile);
1655
+ const stateFile = resolveIssueStatePath(options.stateFile);
1656
+ const todoRaw = await readFile(todoFile, 'utf-8');
1657
+ const pending = parsePendingTodoItems(todoRaw, 'todo.md');
1658
+ const prev = await loadIssueState(stateFile);
1659
+ const result = syncIssueState(prev, pending);
1660
+ await saveIssueState(stateFile, result.state);
1661
+ console.log(JSON.stringify({
1662
+ stateFile,
1663
+ todoFile,
1664
+ pending: pending.length,
1665
+ added: result.added,
1666
+ reopened: result.reopened,
1667
+ autoClosed: result.autoClosed,
1668
+ total: result.state.issues.length,
1669
+ }, null, 2));
1670
+ });
1671
+ issue
1672
+ .command('list')
1673
+ .description('List issues from state file')
1674
+ .option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
1675
+ .option('--status <status>', 'Filter by status: open,in_progress,blocked,done')
1676
+ .action(async (options) => {
1677
+ const stateFile = resolveIssueStatePath(options.stateFile);
1678
+ const state = await loadIssueState(stateFile);
1679
+ const statusFilter = options.status ? String(options.status).trim() : '';
1680
+ const rows = statusFilter
1681
+ ? state.issues.filter((item) => item.status === statusFilter)
1682
+ : state.issues;
1683
+ console.log(JSON.stringify({ stateFile, total: rows.length, issues: rows }, null, 2));
1684
+ });
1685
+ issue
1686
+ .command('set-status')
1687
+ .description('Update one issue status in state file')
1688
+ .requiredOption('--id <id>', 'Issue id, e.g. T2')
1689
+ .requiredOption('--status <status>', 'Target status: open|in_progress|blocked|done')
1690
+ .option('--note <text>', 'Optional status note')
1691
+ .option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
1692
+ .action(async (options) => {
1693
+ const stateFile = resolveIssueStatePath(options.stateFile);
1694
+ const prev = await loadIssueState(stateFile);
1695
+ const next = setIssueStatus(prev, {
1696
+ id: options.id,
1697
+ status: options.status,
1698
+ note: options.note,
1699
+ });
1700
+ await saveIssueState(stateFile, next);
1701
+ const issueItem = next.issues.find((item) => item.id === options.id);
1702
+ console.log(JSON.stringify({ stateFile, issue: issueItem }, null, 2));
1703
+ });
1616
1704
  program
1617
1705
  .command('hook')
1618
1706
  .description('Minimal hook entrypoint (defaults to runtime payload + ~/.env + default export dir)')
@@ -0,0 +1,39 @@
1
+ export declare const ISSUE_STATUSES: readonly ["open", "in_progress", "blocked", "done"];
2
+ export type IssueStatus = (typeof ISSUE_STATUSES)[number];
3
+ export interface IssueItem {
4
+ id: string;
5
+ title: string;
6
+ source: string;
7
+ status: IssueStatus;
8
+ notes: string[];
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ closedAt?: string;
12
+ }
13
+ export interface IssueState {
14
+ version: 1;
15
+ updatedAt: string;
16
+ issues: IssueItem[];
17
+ }
18
+ export interface PendingTodoItem {
19
+ id: string;
20
+ title: string;
21
+ source: string;
22
+ }
23
+ export declare function parsePendingTodoItems(todoContent: string, source?: string): PendingTodoItem[];
24
+ export declare function syncIssueState(prev: IssueState, pending: PendingTodoItem[], now?: string): {
25
+ state: IssueState;
26
+ added: number;
27
+ reopened: number;
28
+ autoClosed: number;
29
+ };
30
+ export declare function setIssueStatus(prev: IssueState, input: {
31
+ id: string;
32
+ status: string;
33
+ note?: string;
34
+ }, now?: string): IssueState;
35
+ export declare function createEmptyIssueState(now?: string): IssueState;
36
+ export declare function loadIssueState(filePath: string): Promise<IssueState>;
37
+ export declare function saveIssueState(filePath: string, state: IssueState): Promise<void>;
38
+ export declare function resolveIssueStatePath(input?: string): string;
39
+ export declare function resolveTodoPath(input?: string): string;
@@ -0,0 +1,151 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const ISSUE_STATUSES = ['open', 'in_progress', 'blocked', 'done'];
4
+ function nowIso(now) {
5
+ return now ?? new Date().toISOString();
6
+ }
7
+ function normalizeId(id) {
8
+ return String(id ?? '').trim();
9
+ }
10
+ function assertStatus(status) {
11
+ if (!ISSUE_STATUSES.includes(status)) {
12
+ throw new Error(`unsupported issue status: ${status}`);
13
+ }
14
+ }
15
+ function assertAllowedTransition(from, to) {
16
+ if (from === to)
17
+ return;
18
+ if (from === 'done' && to !== 'done') {
19
+ throw new Error('cannot transition issue from done to non-done status');
20
+ }
21
+ }
22
+ export function parsePendingTodoItems(todoContent, source = 'todo.md') {
23
+ const lines = String(todoContent ?? '').split(/\r?\n/);
24
+ const items = [];
25
+ for (const line of lines) {
26
+ const m = line.match(/^\s*-\s*\[\s\]\s+([A-Za-z0-9_-]+)\s+(.*)$/);
27
+ if (!m)
28
+ continue;
29
+ items.push({
30
+ id: normalizeId(m[1]),
31
+ title: m[2].trim(),
32
+ source,
33
+ });
34
+ }
35
+ return items;
36
+ }
37
+ export function syncIssueState(prev, pending, now) {
38
+ const timestamp = nowIso(now);
39
+ const byId = new Map(prev.issues.map((issue) => [issue.id, issue]));
40
+ const activeIds = new Set(pending.map((item) => item.id));
41
+ let added = 0;
42
+ let reopened = 0;
43
+ let autoClosed = 0;
44
+ for (const item of pending) {
45
+ const existing = byId.get(item.id);
46
+ if (!existing) {
47
+ byId.set(item.id, {
48
+ id: item.id,
49
+ title: item.title,
50
+ source: item.source,
51
+ status: 'open',
52
+ notes: ['synced from todo pending item'],
53
+ createdAt: timestamp,
54
+ updatedAt: timestamp,
55
+ });
56
+ added += 1;
57
+ continue;
58
+ }
59
+ existing.title = item.title;
60
+ existing.source = item.source;
61
+ existing.updatedAt = timestamp;
62
+ if (existing.status === 'done') {
63
+ existing.status = 'open';
64
+ existing.closedAt = undefined;
65
+ existing.notes.push('reopened by todo pending item');
66
+ reopened += 1;
67
+ }
68
+ }
69
+ for (const issue of byId.values()) {
70
+ if (issue.source !== 'todo.md')
71
+ continue;
72
+ if (activeIds.has(issue.id))
73
+ continue;
74
+ if (issue.status === 'done')
75
+ continue;
76
+ issue.status = 'done';
77
+ issue.updatedAt = timestamp;
78
+ issue.closedAt = timestamp;
79
+ issue.notes.push('auto-closed because todo item is no longer pending');
80
+ autoClosed += 1;
81
+ }
82
+ return {
83
+ state: {
84
+ version: 1,
85
+ updatedAt: timestamp,
86
+ issues: Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)),
87
+ },
88
+ added,
89
+ reopened,
90
+ autoClosed,
91
+ };
92
+ }
93
+ export function setIssueStatus(prev, input, now) {
94
+ const issueId = normalizeId(input.id);
95
+ const timestamp = nowIso(now);
96
+ assertStatus(input.status);
97
+ const issue = prev.issues.find((item) => item.id === issueId);
98
+ if (!issue)
99
+ throw new Error(`issue not found: ${issueId}`);
100
+ assertAllowedTransition(issue.status, input.status);
101
+ issue.status = input.status;
102
+ issue.updatedAt = timestamp;
103
+ if (input.status === 'done')
104
+ issue.closedAt = timestamp;
105
+ if (input.status !== 'done')
106
+ issue.closedAt = undefined;
107
+ if (input.note && input.note.trim())
108
+ issue.notes.push(input.note.trim());
109
+ return {
110
+ version: 1,
111
+ updatedAt: timestamp,
112
+ issues: prev.issues,
113
+ };
114
+ }
115
+ export function createEmptyIssueState(now) {
116
+ return {
117
+ version: 1,
118
+ updatedAt: nowIso(now),
119
+ issues: [],
120
+ };
121
+ }
122
+ export async function loadIssueState(filePath) {
123
+ try {
124
+ const raw = await readFile(filePath, 'utf-8');
125
+ const parsed = JSON.parse(raw);
126
+ if (!parsed || !Array.isArray(parsed.issues)) {
127
+ throw new Error('invalid issue state file');
128
+ }
129
+ return {
130
+ version: 1,
131
+ updatedAt: String(parsed.updatedAt ?? new Date(0).toISOString()),
132
+ issues: parsed.issues,
133
+ };
134
+ }
135
+ catch (error) {
136
+ if (error?.code === 'ENOENT') {
137
+ return createEmptyIssueState();
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+ export async function saveIssueState(filePath, state) {
143
+ await mkdir(path.dirname(filePath), { recursive: true });
144
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
145
+ }
146
+ export function resolveIssueStatePath(input) {
147
+ return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'docs/issues/state.json');
148
+ }
149
+ export function resolveTodoPath(input) {
150
+ return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'todo.md');
151
+ }
@@ -1,8 +1,9 @@
1
1
  // @ts-nocheck
2
- import { readFile } from 'node:fs/promises';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
5
5
  import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
6
+ const INFER_WINDOW_EPSILON_MS = 1200;
6
7
  function parseTimestamp(entry) {
7
8
  const raw = entry?.timestamp;
8
9
  const date = raw ? new Date(raw) : new Date();
@@ -17,6 +18,150 @@ function normalizeIsSidechain(entry) {
17
18
  function normalizeAgentId(entry) {
18
19
  return entry?.agentId ?? entry?.agent_id ?? entry?.message?.agentId ?? entry?.message?.agent_id;
19
20
  }
21
+ function extractToolUses(content) {
22
+ if (!Array.isArray(content))
23
+ return [];
24
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
25
+ }
26
+ function extractToolResults(content) {
27
+ if (!Array.isArray(content))
28
+ return [];
29
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
30
+ }
31
+ function isToolResultOnlyContent(content) {
32
+ return Array.isArray(content)
33
+ && content.length > 0
34
+ && content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
35
+ }
36
+ function isPromptUserMessage(message) {
37
+ if (!message || message.role !== 'user' || message.isMeta)
38
+ return false;
39
+ const { content } = message;
40
+ if (typeof content === 'string')
41
+ return content.trim().length > 0;
42
+ if (!Array.isArray(content))
43
+ return false;
44
+ if (isToolResultOnlyContent(content))
45
+ return false;
46
+ return content.length > 0;
47
+ }
48
+ function firstNonEmpty(values) {
49
+ for (const value of values) {
50
+ const text = String(value ?? '').trim();
51
+ if (text)
52
+ return text;
53
+ }
54
+ return '';
55
+ }
56
+ function findChildSessionHints(messages) {
57
+ const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
58
+ if (!hasSidechainSignal)
59
+ return null;
60
+ const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
61
+ || String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
62
+ || String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
63
+ if (hasParentLink)
64
+ return null;
65
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
66
+ const childStartedAt = sorted[0]?.timestamp;
67
+ if (!childStartedAt)
68
+ return null;
69
+ return {
70
+ childStartedAt,
71
+ agentId: firstNonEmpty(messages.map((m) => m?.agentId)),
72
+ };
73
+ }
74
+ function extractTaskWindows(messages, sessionId) {
75
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
76
+ const windows = [];
77
+ const byCallId = new Map();
78
+ let turnIndex = 0;
79
+ let currentTurnId = 'turn-1';
80
+ for (const msg of sorted) {
81
+ if (isPromptUserMessage(msg)) {
82
+ turnIndex += 1;
83
+ currentTurnId = `turn-${turnIndex}`;
84
+ }
85
+ if (msg.role === 'assistant') {
86
+ for (const tu of extractToolUses(msg.content)) {
87
+ const toolName = String(tu.name ?? '').trim();
88
+ if (toolName !== 'Task')
89
+ continue;
90
+ const callId = String(tu.id ?? '').trim();
91
+ if (!callId)
92
+ continue;
93
+ const window = {
94
+ parentSessionId: sessionId,
95
+ parentTurnId: currentTurnId,
96
+ parentToolCallId: callId,
97
+ startedAtMs: msg.timestamp.getTime(),
98
+ endedAtMs: msg.timestamp.getTime(),
99
+ };
100
+ windows.push(window);
101
+ byCallId.set(callId, window);
102
+ }
103
+ }
104
+ if (msg.role === 'user') {
105
+ for (const tr of extractToolResults(msg.content)) {
106
+ const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
107
+ if (!callId)
108
+ continue;
109
+ const window = byCallId.get(callId);
110
+ if (window) {
111
+ window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return windows;
117
+ }
118
+ async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
119
+ const hints = findChildSessionHints(messages);
120
+ if (!hints)
121
+ return messages;
122
+ const currentSessionId = path.basename(transcriptPath, '.jsonl');
123
+ const dir = path.dirname(transcriptPath);
124
+ let entries = [];
125
+ try {
126
+ entries = await readdir(dir, { withFileTypes: true });
127
+ }
128
+ catch {
129
+ return messages;
130
+ }
131
+ const candidates = [];
132
+ for (const entry of entries) {
133
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
134
+ continue;
135
+ const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
136
+ if (siblingSessionId === currentSessionId)
137
+ continue;
138
+ const siblingPath = path.join(dir, entry.name);
139
+ const siblingMessages = await readClaudeTranscript(siblingPath);
140
+ const windows = extractTaskWindows(siblingMessages, siblingSessionId);
141
+ for (const window of windows) {
142
+ const childAtMs = hints.childStartedAt.getTime();
143
+ const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
144
+ const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
145
+ if (childAtMs < lower || childAtMs > upper)
146
+ continue;
147
+ candidates.push({
148
+ ...window,
149
+ score: Math.abs(childAtMs - window.startedAtMs),
150
+ });
151
+ }
152
+ }
153
+ if (candidates.length === 0)
154
+ return messages;
155
+ candidates.sort((a, b) => a.score - b.score);
156
+ const best = candidates[0];
157
+ return messages.map((msg) => ({
158
+ ...msg,
159
+ parentSessionId: best.parentSessionId,
160
+ parentTurnId: best.parentTurnId,
161
+ parentToolCallId: best.parentToolCallId,
162
+ parentLinkConfidence: 'inferred',
163
+ }));
164
+ }
20
165
  async function readClaudeTranscript(transcriptPath) {
21
166
  const raw = await readFile(transcriptPath, 'utf-8');
22
167
  const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
@@ -29,6 +174,9 @@ async function readClaudeTranscript(transcriptPath) {
29
174
  isMeta: entry.isMeta ?? false,
30
175
  isSidechain: normalizeIsSidechain(entry),
31
176
  agentId: normalizeAgentId(entry),
177
+ parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
178
+ parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
179
+ parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
32
180
  content: entry?.message?.content ?? entry.content ?? '',
33
181
  model: entry?.message?.model ?? entry.model,
34
182
  usage: pickUsage(entry?.message?.usage ?? entry?.usage ?? entry?.message_usage),
@@ -62,7 +210,8 @@ export const claudeCodeAdapter = {
62
210
  async collectEvents(context) {
63
211
  const transcriptPath = context.transcriptPath ??
64
212
  path.join(process.env.HOME || '', '.claude', 'projects', context.projectId, `${context.sessionId}.jsonl`);
65
- const messages = await readClaudeTranscript(transcriptPath);
213
+ const loaded = await readClaudeTranscript(transcriptPath);
214
+ const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
66
215
  return normalizeTranscriptMessages({
67
216
  runtime: 'claude-code',
68
217
  projectId: context.projectId,
@@ -77,6 +77,21 @@ function usageFromTotals(start, end) {
77
77
  total_tokens: total || input + output,
78
78
  });
79
79
  }
80
+ function extractPtySessionId(output) {
81
+ const text = String(output ?? '');
82
+ const match = text.match(/session ID\s+(\d+)/i);
83
+ return match ? match[1] : undefined;
84
+ }
85
+ function extractWallTimeMs(output) {
86
+ const text = String(output ?? '');
87
+ const match = text.match(/Wall time:\s*([0-9]+(?:\.[0-9]+)?)\s*seconds?/i);
88
+ if (!match)
89
+ return undefined;
90
+ const seconds = Number(match[1]);
91
+ if (!Number.isFinite(seconds) || seconds <= 0)
92
+ return undefined;
93
+ return Math.floor(seconds * 1000);
94
+ }
80
95
  function createTurn(turnId, startedAt) {
81
96
  return {
82
97
  turnId,
@@ -194,6 +209,7 @@ async function readCodexSession(transcriptPath) {
194
209
  let sessionMeta = null;
195
210
  let callCounter = 0;
196
211
  const callIndex = new Map();
212
+ const ptyCallBySession = new Map();
197
213
  function finalizeCurrentTurn(at) {
198
214
  if (!currentTurn)
199
215
  return;
@@ -323,6 +339,11 @@ async function readCodexSession(transcriptPath) {
323
339
  const rawName = payload.name ?? payload.tool_name ?? payload.toolName;
324
340
  const rawInput = payload.type === 'custom_tool_call' ? payload.input : payload.arguments;
325
341
  const args = safeJsonParse(rawInput, typeof rawInput === 'string' ? { raw: rawInput } : {});
342
+ const ptySessionId = args.session_id != null ? String(args.session_id) : '';
343
+ if (String(rawName ?? '') === 'write_stdin' && ptySessionId && ptyCallBySession.has(ptySessionId)) {
344
+ callIndex.set(String(payload.call_id ?? payload.callId ?? `call-${callCounter}`), ptyCallBySession.get(ptySessionId));
345
+ continue;
346
+ }
326
347
  const normalized = normalizeToolCall(rawName, args, callCounter);
327
348
  const callId = String(payload.call_id ?? payload.callId ?? normalized.callId ?? `call-${callCounter}`);
328
349
  const call = {
@@ -341,8 +362,22 @@ async function readCodexSession(transcriptPath) {
341
362
  const callId = String(payload.call_id ?? payload.callId ?? '');
342
363
  const call = callIndex.get(callId);
343
364
  if (call) {
344
- call.output = String(payload.output ?? '');
365
+ const output = String(payload.output ?? '');
366
+ if (output)
367
+ call.output = output;
345
368
  call.endedAt = isoAt;
369
+ const wallTimeMs = extractWallTimeMs(output);
370
+ if (wallTimeMs) {
371
+ const derivedEndedAt = new Date(parseTimestamp(call.startedAt).getTime() + wallTimeMs).toISOString();
372
+ if (parseTimestamp(derivedEndedAt).getTime() > parseTimestamp(call.endedAt).getTime()) {
373
+ call.endedAt = derivedEndedAt;
374
+ }
375
+ }
376
+ if (call.toolName === 'Bash') {
377
+ const ptySessionId = extractPtySessionId(output);
378
+ if (ptySessionId)
379
+ ptyCallBySession.set(ptySessionId, call);
380
+ }
346
381
  }
347
382
  continue;
348
383
  }
@@ -398,11 +433,12 @@ function remapTurnIds(events, turns) {
398
433
  function attachCwdAttribute(events, cwd) {
399
434
  if (!cwd)
400
435
  return events;
436
+ const sanitizedCwd = deriveProjectIdFromCwd(cwd);
401
437
  return events.map((event) => ({
402
438
  ...event,
403
439
  attributes: {
404
440
  ...(event.attributes ?? {}),
405
- 'agentic.project.cwd': cwd,
441
+ 'agentic.project.cwd': sanitizedCwd,
406
442
  },
407
443
  }));
408
444
  }
@@ -1,8 +1,9 @@
1
1
  // @ts-nocheck
2
- import { readFile, stat } from 'node:fs/promises';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
5
5
  import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
6
+ const INFER_WINDOW_EPSILON_MS = 1200;
6
7
  function parseTimestamp(entry) {
7
8
  const raw = entry?.timestamp ?? entry?.created_at ?? entry?.createdAt;
8
9
  const date = raw ? new Date(raw) : new Date();
@@ -140,6 +141,139 @@ function normalizeAgentId(entry) {
140
141
  ?? entry?.payload?.agentId
141
142
  ?? entry?.payload?.agent_id);
142
143
  }
144
+ function extractToolUses(content) {
145
+ if (!Array.isArray(content))
146
+ return [];
147
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
148
+ }
149
+ function extractToolResults(content) {
150
+ if (!Array.isArray(content))
151
+ return [];
152
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
153
+ }
154
+ function isToolResultOnlyContent(content) {
155
+ return Array.isArray(content)
156
+ && content.length > 0
157
+ && content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
158
+ }
159
+ function isPromptUserMessage(message) {
160
+ if (!message || message.role !== 'user' || message.isMeta)
161
+ return false;
162
+ const { content } = message;
163
+ if (typeof content === 'string')
164
+ return content.trim().length > 0;
165
+ if (!Array.isArray(content))
166
+ return false;
167
+ if (isToolResultOnlyContent(content))
168
+ return false;
169
+ return content.length > 0;
170
+ }
171
+ function findChildSessionHints(messages) {
172
+ const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
173
+ if (!hasSidechainSignal)
174
+ return null;
175
+ const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
176
+ || String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
177
+ || String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
178
+ if (hasParentLink)
179
+ return null;
180
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
181
+ const childStartedAt = sorted[0]?.timestamp;
182
+ if (!childStartedAt)
183
+ return null;
184
+ return { childStartedAt };
185
+ }
186
+ function extractTaskWindows(messages, sessionId) {
187
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
188
+ const windows = [];
189
+ const byCallId = new Map();
190
+ let turnIndex = 0;
191
+ let currentTurnId = 'turn-1';
192
+ for (const msg of sorted) {
193
+ if (isPromptUserMessage(msg)) {
194
+ turnIndex += 1;
195
+ currentTurnId = `turn-${turnIndex}`;
196
+ }
197
+ if (msg.role === 'assistant') {
198
+ for (const tu of extractToolUses(msg.content)) {
199
+ const toolName = String(tu.name ?? '').trim();
200
+ if (toolName !== 'Task')
201
+ continue;
202
+ const callId = String(tu.id ?? '').trim();
203
+ if (!callId)
204
+ continue;
205
+ const window = {
206
+ parentSessionId: sessionId,
207
+ parentTurnId: currentTurnId,
208
+ parentToolCallId: callId,
209
+ startedAtMs: msg.timestamp.getTime(),
210
+ endedAtMs: msg.timestamp.getTime(),
211
+ };
212
+ windows.push(window);
213
+ byCallId.set(callId, window);
214
+ }
215
+ }
216
+ if (msg.role === 'user') {
217
+ for (const tr of extractToolResults(msg.content)) {
218
+ const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
219
+ if (!callId)
220
+ continue;
221
+ const window = byCallId.get(callId);
222
+ if (window) {
223
+ window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return windows;
229
+ }
230
+ async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
231
+ const hints = findChildSessionHints(messages);
232
+ if (!hints)
233
+ return messages;
234
+ const currentSessionId = path.basename(transcriptPath, '.jsonl');
235
+ const dir = path.dirname(transcriptPath);
236
+ let entries = [];
237
+ try {
238
+ entries = await readdir(dir, { withFileTypes: true });
239
+ }
240
+ catch {
241
+ return messages;
242
+ }
243
+ const candidates = [];
244
+ for (const entry of entries) {
245
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
246
+ continue;
247
+ const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
248
+ if (siblingSessionId === currentSessionId)
249
+ continue;
250
+ const siblingPath = path.join(dir, entry.name);
251
+ const siblingMessages = await readOpenclawTranscript(siblingPath);
252
+ const windows = extractTaskWindows(siblingMessages, siblingSessionId);
253
+ for (const window of windows) {
254
+ const childAtMs = hints.childStartedAt.getTime();
255
+ const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
256
+ const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
257
+ if (childAtMs < lower || childAtMs > upper)
258
+ continue;
259
+ candidates.push({
260
+ ...window,
261
+ score: Math.abs(childAtMs - window.startedAtMs),
262
+ });
263
+ }
264
+ }
265
+ if (candidates.length === 0)
266
+ return messages;
267
+ candidates.sort((a, b) => a.score - b.score);
268
+ const best = candidates[0];
269
+ return messages.map((msg) => ({
270
+ ...msg,
271
+ parentSessionId: best.parentSessionId,
272
+ parentTurnId: best.parentTurnId,
273
+ parentToolCallId: best.parentToolCallId,
274
+ parentLinkConfidence: 'inferred',
275
+ }));
276
+ }
143
277
  async function readOpenclawTranscript(transcriptPath) {
144
278
  const raw = await readFile(transcriptPath, 'utf-8');
145
279
  const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
@@ -165,6 +299,9 @@ async function readOpenclawTranscript(transcriptPath) {
165
299
  isMeta: entry?.isMeta ?? entry?.is_meta ?? false,
166
300
  isSidechain: normalizeIsSidechain(entry),
167
301
  agentId: normalizeAgentId(entry),
302
+ parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
303
+ parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
304
+ parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
168
305
  content: normalizeContent(entry),
169
306
  model: normalizeModel(entry),
170
307
  usage: normalizeUsage(entry),
@@ -231,7 +368,8 @@ export const openclawAdapter = {
231
368
  },
232
369
  async collectEvents(context) {
233
370
  const transcriptPath = await resolveTranscriptPath(context);
234
- const messages = await readOpenclawTranscript(transcriptPath);
371
+ const loaded = await readOpenclawTranscript(transcriptPath);
372
+ const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
235
373
  return normalizeTranscriptMessages({
236
374
  runtime: 'openclaw',
237
375
  projectId: context.projectId,
@@ -0,0 +1,19 @@
1
+ type FileStat = {
2
+ mtimeMs: number;
3
+ };
4
+ type WaitForFileSettleOptions = {
5
+ filePath: string;
6
+ stableWindowMs?: number;
7
+ timeoutMs?: number;
8
+ pollMs?: number;
9
+ statFn?: (filePath: string) => Promise<FileStat>;
10
+ sleepFn?: (ms: number) => Promise<void>;
11
+ nowFn?: () => number;
12
+ };
13
+ type WaitForFileSettleResult = {
14
+ settled: boolean;
15
+ lastMtimeMs?: number;
16
+ waitedMs: number;
17
+ };
18
+ export declare function waitForFileMtimeToSettle(options: WaitForFileSettleOptions): Promise<WaitForFileSettleResult>;
19
+ export {};
@@ -0,0 +1,44 @@
1
+ import { stat } from 'node:fs/promises';
2
+ function sleep(ms) {
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
5
+ export async function waitForFileMtimeToSettle(options) {
6
+ const stableWindowMs = options.stableWindowMs ?? 400;
7
+ const timeoutMs = options.timeoutMs ?? 2500;
8
+ const pollMs = options.pollMs ?? 100;
9
+ const statFn = options.statFn ?? (async (filePath) => stat(filePath));
10
+ const sleepFn = options.sleepFn ?? sleep;
11
+ const nowFn = options.nowFn ?? Date.now;
12
+ const startedAt = nowFn();
13
+ const deadline = startedAt + timeoutMs;
14
+ let lastMtimeMs;
15
+ let unchangedSinceMs;
16
+ while (nowFn() <= deadline) {
17
+ const current = await statFn(options.filePath);
18
+ const mtimeMs = Number(current.mtimeMs);
19
+ if (lastMtimeMs === mtimeMs) {
20
+ if (unchangedSinceMs === undefined)
21
+ unchangedSinceMs = nowFn();
22
+ if (nowFn() - unchangedSinceMs >= stableWindowMs) {
23
+ return {
24
+ settled: true,
25
+ lastMtimeMs: mtimeMs,
26
+ waitedMs: nowFn() - startedAt,
27
+ };
28
+ }
29
+ }
30
+ else {
31
+ lastMtimeMs = mtimeMs;
32
+ unchangedSinceMs = undefined;
33
+ }
34
+ const remainingMs = deadline - nowFn();
35
+ if (remainingMs <= 0)
36
+ break;
37
+ await sleepFn(Math.min(pollMs, remainingMs));
38
+ }
39
+ return {
40
+ settled: false,
41
+ lastMtimeMs,
42
+ waitedMs: nowFn() - startedAt,
43
+ };
44
+ }
@@ -210,6 +210,28 @@ function parseSlashCommand(text) {
210
210
  args: plain[2] ? plain[2].trim() : '',
211
211
  };
212
212
  }
213
+ function parseBashCommandAttributes(commandLine) {
214
+ const raw = String(commandLine ?? '').trim();
215
+ if (!raw) {
216
+ return {
217
+ 'agentic.command.name': '',
218
+ 'agentic.command.args': '',
219
+ 'agentic.command.pipe_count': 0,
220
+ 'agentic.command.raw': '',
221
+ };
222
+ }
223
+ const segments = raw.split(/\|(?!\|)/);
224
+ const firstSegment = String(segments[0] ?? '').trim();
225
+ const tokens = firstSegment ? firstSegment.split(/\s+/) : [];
226
+ const name = String(tokens[0] ?? '').trim();
227
+ const args = tokens.length > 1 ? tokens.slice(1).join(' ') : '';
228
+ return {
229
+ 'agentic.command.name': name,
230
+ 'agentic.command.args': args,
231
+ 'agentic.command.pipe_count': Math.max(segments.length - 1, 0),
232
+ 'agentic.command.raw': raw,
233
+ };
234
+ }
213
235
  function isMcpToolName(name) {
214
236
  const n = String(name || '').toLowerCase();
215
237
  return n === 'mcp' || n.startsWith('mcp__') || n.startsWith('mcp-');
@@ -252,6 +274,55 @@ function actorHeuristic(messages) {
252
274
  return { role: 'unknown', confidence: 0.6 };
253
275
  return { role: 'main', confidence: 0.95 };
254
276
  }
277
+ function firstNonEmptyString(values) {
278
+ for (const value of values) {
279
+ const text = String(value ?? '').trim();
280
+ if (text)
281
+ return text;
282
+ }
283
+ return '';
284
+ }
285
+ function inferParentLinkAttributes(messages) {
286
+ const agentId = firstNonEmptyString(messages.map((m) => m?.agentId ?? m?.agent_id ?? m?.message?.agentId ?? m?.message?.agent_id));
287
+ const parentSessionId = firstNonEmptyString(messages.map((m) => m?.parentSessionId
288
+ ?? m?.parent_session_id
289
+ ?? m?.parent?.sessionId
290
+ ?? m?.parent?.session_id
291
+ ?? m?.session_meta?.parent_session_id
292
+ ?? m?.sessionMeta?.parentSessionId));
293
+ const parentTurnId = firstNonEmptyString(messages.map((m) => m?.parentTurnId
294
+ ?? m?.parent_turn_id
295
+ ?? m?.parent?.turnId
296
+ ?? m?.parent?.turn_id
297
+ ?? m?.session_meta?.parent_turn_id
298
+ ?? m?.sessionMeta?.parentTurnId));
299
+ const parentToolCallId = firstNonEmptyString(messages.map((m) => m?.parentToolCallId
300
+ ?? m?.parent_tool_call_id
301
+ ?? m?.parent?.toolCallId
302
+ ?? m?.parent?.tool_call_id
303
+ ?? m?.session_meta?.parent_tool_call_id
304
+ ?? m?.sessionMeta?.parentToolCallId));
305
+ const explicitConfidence = firstNonEmptyString(messages.map((m) => m?.parentLinkConfidence ?? m?.parent_link_confidence));
306
+ const attrs = {};
307
+ if (agentId)
308
+ attrs['agentic.agent_id'] = agentId;
309
+ if (parentSessionId)
310
+ attrs['agentic.parent.session_id'] = parentSessionId;
311
+ if (parentTurnId)
312
+ attrs['agentic.parent.turn_id'] = parentTurnId;
313
+ if (parentToolCallId)
314
+ attrs['agentic.parent.tool_call_id'] = parentToolCallId;
315
+ if (explicitConfidence) {
316
+ attrs['agentic.parent.link.confidence'] = explicitConfidence;
317
+ }
318
+ else if (parentSessionId || parentTurnId || parentToolCallId) {
319
+ attrs['agentic.parent.link.confidence'] = 'exact';
320
+ }
321
+ else if (agentId) {
322
+ attrs['agentic.parent.link.confidence'] = 'unknown';
323
+ }
324
+ return attrs;
325
+ }
255
326
  function createTurn(messages, turnId, projectId, sessionId, runtime) {
256
327
  const user = messages.find(isPromptUserMessage) ?? messages.find((m) => m.role === 'user' && !m.isMeta) ?? messages[0];
257
328
  const assistantsRaw = messages.filter((m) => m.role === 'assistant');
@@ -283,6 +354,8 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
283
354
  }
284
355
  const usage = Object.keys(totalUsage).length ? totalUsage : undefined;
285
356
  const actor = actorHeuristic(messages);
357
+ const parentLinkAttrs = inferParentLinkAttributes(messages);
358
+ const sharedAttrs = { ...runtimeAttrs, ...parentLinkAttrs };
286
359
  const events = [
287
360
  {
288
361
  runtime,
@@ -299,7 +372,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
299
372
  'agentic.event.category': 'turn',
300
373
  'langfuse.observation.type': 'agent',
301
374
  'gen_ai.operation.name': 'invoke_agent',
302
- ...runtimeAttrs,
375
+ ...sharedAttrs,
303
376
  ...modelAttributes(latestModel),
304
377
  'agentic.actor.role': actor.role,
305
378
  'agentic.actor.role_confidence': actor.confidence,
@@ -348,7 +421,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
348
421
  attributes: {
349
422
  'agentic.event.category': isMcp ? 'mcp' : 'agent_command',
350
423
  'langfuse.observation.type': isMcp ? 'tool' : 'event',
351
- ...runtimeAttrs,
424
+ ...sharedAttrs,
352
425
  'agentic.command.name': slash.name,
353
426
  'agentic.command.args': slash.args,
354
427
  'gen_ai.operation.name': isMcp ? 'execute_tool' : 'invoke_agent',
@@ -377,7 +450,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
377
450
  attributes: {
378
451
  'agentic.event.category': 'reasoning',
379
452
  'langfuse.observation.type': 'span',
380
- ...runtimeAttrs,
453
+ ...sharedAttrs,
381
454
  'gen_ai.operation.name': 'invoke_agent',
382
455
  ...modelAttributes(assistant.model),
383
456
  },
@@ -408,8 +481,9 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
408
481
  attributes: {
409
482
  'agentic.event.category': 'shell_command',
410
483
  'langfuse.observation.type': 'tool',
411
- ...runtimeAttrs,
484
+ ...sharedAttrs,
412
485
  'process.command_line': commandLine,
486
+ ...parseBashCommandAttributes(commandLine),
413
487
  'gen_ai.tool.name': 'Bash',
414
488
  'gen_ai.tool.call.id': toolId,
415
489
  'gen_ai.operation.name': 'execute_tool',
@@ -435,7 +509,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
435
509
  attributes: {
436
510
  'agentic.event.category': 'mcp',
437
511
  'langfuse.observation.type': 'tool',
438
- ...runtimeAttrs,
512
+ ...sharedAttrs,
439
513
  'gen_ai.tool.name': toolName,
440
514
  'mcp.request.id': toolId,
441
515
  'gen_ai.operation.name': 'execute_tool',
@@ -461,7 +535,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
461
535
  attributes: {
462
536
  'agentic.event.category': 'agent_task',
463
537
  'langfuse.observation.type': 'agent',
464
- ...runtimeAttrs,
538
+ ...sharedAttrs,
465
539
  'gen_ai.tool.name': 'Task',
466
540
  'gen_ai.tool.call.id': toolId,
467
541
  'gen_ai.operation.name': 'invoke_agent',
@@ -486,7 +560,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
486
560
  attributes: {
487
561
  'agentic.event.category': 'tool',
488
562
  'langfuse.observation.type': 'tool',
489
- ...runtimeAttrs,
563
+ ...sharedAttrs,
490
564
  'gen_ai.tool.name': toolName,
491
565
  'gen_ai.tool.call.id': toolId,
492
566
  'gen_ai.operation.name': 'execute_tool',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bububuger/spanory",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Spanory CLI for cross-runtime agent observability",
5
5
  "license": "MIT",
6
6
  "type": "module",