opencode-onboard 0.2.6 → 0.2.7

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.
@@ -2,14 +2,29 @@ import fs from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
4
  const LOG_FILE = ".agents/session-log.json"
5
+ const SPAWN_MATCH_WINDOW_MS = 15000
6
+ const DEBUG = process.env.SESSION_LOG_DEBUG === "true"
5
7
 
6
- // Per-session state: editCount and loaded skills
8
+ // Per-session state
7
9
  const sessionState = new Map()
8
10
 
11
+ // Lead session -> current team name
12
+ const leadTeamBySession = new Map()
13
+
14
+ // Pending spawn records waiting for a session.created match
15
+ const pendingSpawns = []
16
+
17
+ // Team -> completed session snapshots
18
+ const completedByTeam = new Map()
19
+
9
20
  function ts() {
10
21
  return new Date().toISOString()
11
22
  }
12
23
 
24
+ function nowMs() {
25
+ return Date.now()
26
+ }
27
+
13
28
  function appendEntry(directory, entry) {
14
29
  const logPath = path.join(directory, LOG_FILE)
15
30
  const dir = path.dirname(logPath)
@@ -40,35 +55,148 @@ function addSkillToState(state, skillName) {
40
55
  return true
41
56
  }
42
57
 
43
- function buildTeamSkillsSummary() {
58
+ function toNum(v) {
59
+ if (typeof v === "number" && Number.isFinite(v)) return v
60
+ if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v)
61
+ return null
62
+ }
63
+
64
+ function extractReportedTokens(obj) {
65
+ const out = { input: 0, output: 0, total: 0, found: false }
66
+ const visited = new Set()
67
+
68
+ function walk(node) {
69
+ if (!node || typeof node !== "object") return
70
+ if (visited.has(node)) return
71
+ visited.add(node)
72
+
73
+ for (const [k, v] of Object.entries(node)) {
74
+ const key = String(k).toLowerCase()
75
+ const n = toNum(v)
76
+
77
+ if (n !== null) {
78
+ if ((key.includes("input") || key.includes("prompt")) && key.includes("token")) {
79
+ out.input += n
80
+ out.found = true
81
+ } else if ((key.includes("output") || key.includes("completion")) && key.includes("token")) {
82
+ out.output += n
83
+ out.found = true
84
+ } else if (key.includes("total") && key.includes("token")) {
85
+ out.total += n
86
+ out.found = true
87
+ }
88
+ }
89
+
90
+ if (v && typeof v === "object") walk(v)
91
+ }
92
+ }
93
+
94
+ walk(obj)
95
+ return out
96
+ }
97
+
98
+ function estimateTokens(state) {
99
+ const inTokens = Math.ceil((state.charIn || 0) / 4)
100
+ const outTokens = Math.ceil((state.charOut || 0) / 4)
101
+ const base = inTokens + outTokens + Math.max(0, (state.toolCalls || 0) * 20)
102
+ const low = Math.max(0, Math.floor(base * 0.7))
103
+ const high = Math.max(low, Math.ceil(base * 1.4))
104
+ return { low, high }
105
+ }
106
+
107
+ function usagePayload(state) {
108
+ const est = estimateTokens(state)
109
+ const reportedIn = state.reportedInputTokens || 0
110
+ const reportedOut = state.reportedOutputTokens || 0
111
+ const reportedTotalDirect = state.reportedTotalTokens || 0
112
+ const reportedTotalDerived = reportedIn + reportedOut
113
+ const reportedTotal = reportedTotalDirect || reportedTotalDerived || 0
114
+
115
+ let method = "heuristic"
116
+ if (reportedTotal > 0 && (est.low > 0 || est.high > 0)) method = "mixed"
117
+ else if (reportedTotal > 0) method = "reported"
118
+
119
+ return {
120
+ inputTokensReported: reportedIn || null,
121
+ outputTokensReported: reportedOut || null,
122
+ totalTokensReported: reportedTotal || null,
123
+ tokenEstimateLow: est.low,
124
+ tokenEstimateHigh: est.high,
125
+ method,
126
+ }
127
+ }
128
+
129
+ function buildCompletedSnapshot(state, sessionId) {
130
+ return {
131
+ sessionId,
132
+ agent: state.agentName,
133
+ member: state.member || null,
134
+ agentType: state.agentType || null,
135
+ team: state.teamName || null,
136
+ skills: Array.from(state.skills || []).sort(),
137
+ usage: usagePayload(state),
138
+ filesEdited: state.editCount || 0,
139
+ }
140
+ }
141
+
142
+ function buildTeamSkillsSummary(teamName) {
143
+ const rows = completedByTeam.get(teamName) || []
44
144
  const byAgent = {}
45
- for (const state of sessionState.values()) {
46
- if (!state?.agentName) continue
47
- if (!byAgent[state.agentName]) byAgent[state.agentName] = new Set()
48
- for (const skill of state.skills ?? []) byAgent[state.agentName].add(skill)
145
+ for (const row of rows) {
146
+ const key = row.member || row.agent || "unknown"
147
+ if (!byAgent[key]) byAgent[key] = { agentType: row.agentType || null, skills: new Set() }
148
+ for (const s of row.skills || []) byAgent[key].skills.add(s)
49
149
  }
150
+ const out = {}
151
+ for (const [k, v] of Object.entries(byAgent)) {
152
+ out[k] = {
153
+ agentType: v.agentType,
154
+ skills: Array.from(v.skills).sort(),
155
+ }
156
+ }
157
+ return out
158
+ }
50
159
 
51
- const summary = {}
52
- for (const [agent, skills] of Object.entries(byAgent)) {
53
- summary[agent] = Array.from(skills).sort()
160
+ function trackCompletedByTeam(snapshot) {
161
+ if (!snapshot.team) return
162
+ if (!completedByTeam.has(snapshot.team)) completedByTeam.set(snapshot.team, [])
163
+ completedByTeam.get(snapshot.team).push(snapshot)
164
+ }
165
+
166
+ function enqueuePendingSpawn(leadSessionId, args) {
167
+ pendingSpawns.push({
168
+ leadSessionId,
169
+ at: nowMs(),
170
+ member: args?.name || null,
171
+ agentType: args?.agent || null,
172
+ teamName: leadTeamBySession.get(leadSessionId) || null,
173
+ })
174
+ }
175
+
176
+ function matchPendingSpawn() {
177
+ const now = nowMs()
178
+ // Drop expired pending spawns first
179
+ for (let i = pendingSpawns.length - 1; i >= 0; i--) {
180
+ if (now - pendingSpawns[i].at > SPAWN_MATCH_WINDOW_MS) pendingSpawns.splice(i, 1)
54
181
  }
55
- return summary
182
+ if (pendingSpawns.length === 0) return null
183
+ return pendingSpawns.shift()
56
184
  }
57
185
 
58
- // Maps ensemble tool name function that extracts the log entry fields from args
186
+ // Maps ensemble tool name -> function that extracts log entry fields from args
59
187
  const ENSEMBLE_TOOL_HANDLERS = {
60
- team_create: (args) => ({ action: "team-created", team: args.name }),
61
- team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
62
- team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
63
- team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
64
- team_cleanup: () => ({ action: "team-cleanup" }),
65
- team_status: () => ({ action: "team-status-checked" }),
66
- team_results: (args) => ({ action: "team-results-read", from: args.from }),
67
- team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
68
- team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
69
- team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
70
- team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
71
- team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
188
+ team_create: (args) => ({ action: "team-created", team: args.name }),
189
+ team_spawn: (args) => ({ action: "teammate-spawned", name: args.name, agentType: args.agent }),
190
+ team_shutdown: (args) => ({ action: "teammate-shutdown", name: args.name }),
191
+ team_merge: (args) => ({ action: "teammate-merged", name: args.name }),
192
+ team_cleanup: () => ({ action: "team-cleanup" }),
193
+ team_status: () => ({ action: "team-status-checked" }),
194
+ team_results: (args) => ({ action: "team-results-read", from: args.from }),
195
+ team_message: (args) => ({ action: "team-message", to: args.to ?? "lead", preview: String(args.text ?? "").slice(0, 120) }),
196
+ team_broadcast: (args) => ({ action: "team-broadcast", preview: String(args.text ?? "").slice(0, 120) }),
197
+ team_tasks_add: (args) => ({ action: "tasks-added", count: Array.isArray(args.tasks) ? args.tasks.length : "?" }),
198
+ team_tasks_complete: (args) => ({ action: "task-completed", taskId: args.task_id }),
199
+ team_claim: (args) => ({ action: "task-claimed", taskId: args.task_id }),
72
200
  }
73
201
 
74
202
  export const SessionLogPlugin = async ({ client, directory }) => {
@@ -81,10 +209,35 @@ export const SessionLogPlugin = async ({ client, directory }) => {
81
209
 
82
210
  const res = await client.session.get({ path: { id: sessionId } })
83
211
  const session = res?.data
84
- const agentName = resolveAgentName(session)
212
+ const fallbackAgent = resolveAgentName(session)
213
+ const spawnMatch = matchPendingSpawn()
214
+
215
+ const state = {
216
+ agentName: spawnMatch?.member || fallbackAgent,
217
+ member: spawnMatch?.member || null,
218
+ agentType: spawnMatch?.agentType || null,
219
+ teamName: spawnMatch?.teamName || null,
220
+ editCount: 0,
221
+ skills: new Set(),
222
+ startedAtMs: nowMs(),
223
+ toolCalls: 0,
224
+ charIn: 0,
225
+ charOut: 0,
226
+ reportedInputTokens: 0,
227
+ reportedOutputTokens: 0,
228
+ reportedTotalTokens: 0,
229
+ }
85
230
 
86
- sessionState.set(sessionId, { agentName, editCount: 0, skills: new Set() })
87
- appendEntry(directory, { ts: ts(), agent: agentName, action: "started", sessionId })
231
+ sessionState.set(sessionId, state)
232
+ appendEntry(directory, {
233
+ ts: ts(),
234
+ agent: state.agentName,
235
+ member: state.member,
236
+ agentType: state.agentType,
237
+ team: state.teamName,
238
+ action: "started",
239
+ sessionId,
240
+ })
88
241
  }
89
242
 
90
243
  if (event?.type === "file.edited") {
@@ -100,9 +253,21 @@ export const SessionLogPlugin = async ({ client, directory }) => {
100
253
  const state = sessionState.get(sessionId)
101
254
  if (!state) return
102
255
 
103
- const { agentName, editCount } = state
104
- const skills = Array.from(state.skills ?? []).sort()
105
- appendEntry(directory, { ts: ts(), agent: agentName, action: "completed", filesEdited: editCount, skills })
256
+ const skills = Array.from(state.skills || []).sort()
257
+ const usage = usagePayload(state)
258
+ appendEntry(directory, {
259
+ ts: ts(),
260
+ agent: state.agentName,
261
+ member: state.member,
262
+ agentType: state.agentType,
263
+ team: state.teamName,
264
+ action: "completed",
265
+ filesEdited: state.editCount,
266
+ skills,
267
+ usage,
268
+ })
269
+
270
+ trackCompletedByTeam(buildCompletedSnapshot(state, sessionId))
106
271
  sessionState.delete(sessionId)
107
272
  }
108
273
  } catch (_) {}
@@ -117,13 +282,49 @@ export const SessionLogPlugin = async ({ client, directory }) => {
117
282
  if (!state) return
118
283
 
119
284
  const tool = input?.tool
285
+ const args = input?.args ?? {}
286
+
287
+ state.toolCalls++
288
+ state.charIn += JSON.stringify(args).length
289
+ state.charOut += JSON.stringify(output ?? {}).length
290
+
291
+ const reportedIn = extractReportedTokens(input)
292
+ const reportedOut = extractReportedTokens(output)
293
+ if (reportedIn.found) {
294
+ state.reportedInputTokens += reportedIn.input
295
+ state.reportedOutputTokens += reportedIn.output
296
+ state.reportedTotalTokens += reportedIn.total
297
+ }
298
+ if (reportedOut.found) {
299
+ state.reportedInputTokens += reportedOut.input
300
+ state.reportedOutputTokens += reportedOut.output
301
+ state.reportedTotalTokens += reportedOut.total
302
+ }
303
+
304
+ if (DEBUG && !reportedIn.found && !reportedOut.found && tool !== "read") {
305
+ appendEntry(directory, {
306
+ ts: ts(),
307
+ agent: state.agentName,
308
+ action: "debug-no-token-metrics",
309
+ tool,
310
+ })
311
+ }
120
312
 
121
313
  // Track skill loads via skill tool (primary)
122
314
  if (tool === "skill") {
123
315
  const skillName = input?.args?.name
124
316
  const added = addSkillToState(state, skillName)
125
317
  if (added) {
126
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "skill-tool" })
318
+ appendEntry(directory, {
319
+ ts: ts(),
320
+ agent: state.agentName,
321
+ member: state.member,
322
+ agentType: state.agentType,
323
+ team: state.teamName,
324
+ action: "skill-loaded",
325
+ skill: skillName,
326
+ source: "skill-tool",
327
+ })
127
328
  }
128
329
  return
129
330
  }
@@ -136,23 +337,53 @@ export const SessionLogPlugin = async ({ client, directory }) => {
136
337
  const skillName = match[1]
137
338
  const added = addSkillToState(state, skillName)
138
339
  if (added) {
139
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "skill-loaded", skill: skillName, source: "read-skill-file" })
340
+ appendEntry(directory, {
341
+ ts: ts(),
342
+ agent: state.agentName,
343
+ member: state.member,
344
+ agentType: state.agentType,
345
+ team: state.teamName,
346
+ action: "skill-loaded",
347
+ skill: skillName,
348
+ source: "read-skill-file",
349
+ })
140
350
  }
141
351
  }
142
352
  return
143
353
  }
144
354
 
145
- const args = input?.args ?? {}
146
-
147
355
  // Track ensemble tool calls
148
356
  const ensembleHandler = ENSEMBLE_TOOL_HANDLERS[tool]
149
357
  if (!ensembleHandler) return
150
358
 
151
- const entry = { ts: ts(), agent: state.agentName, ...ensembleHandler(args) }
359
+ const entry = {
360
+ ts: ts(),
361
+ agent: state.agentName,
362
+ member: state.member,
363
+ agentType: state.agentType,
364
+ team: state.teamName,
365
+ ...ensembleHandler(args),
366
+ }
152
367
  appendEntry(directory, entry)
153
368
 
369
+ if (tool === "team_create") {
370
+ leadTeamBySession.set(sessionId, args?.name || null)
371
+ state.teamName = args?.name || state.teamName
372
+ }
373
+
374
+ if (tool === "team_spawn") {
375
+ enqueuePendingSpawn(sessionId, args)
376
+ }
377
+
154
378
  if (tool === "team_cleanup") {
155
- appendEntry(directory, { ts: ts(), agent: state.agentName, action: "team-skills-summary", byAgent: buildTeamSkillsSummary() })
379
+ const teamName = state.teamName || leadTeamBySession.get(sessionId)
380
+ appendEntry(directory, {
381
+ ts: ts(),
382
+ agent: state.agentName,
383
+ action: "team-skills-summary",
384
+ team: teamName || null,
385
+ byAgent: teamName ? buildTeamSkillsSummary(teamName) : {},
386
+ })
156
387
  }
157
388
  } catch (_) {}
158
389
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",