@team-semicolon/semo-cli 4.1.0 → 4.1.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.
@@ -330,6 +330,11 @@ function registerBotsCommands(program) {
330
330
  }
331
331
  }
332
332
  await client.query("COMMIT");
333
+ // session_count를 bot_sessions 실제 집계로 갱신
334
+ await client.query(`UPDATE semo.bot_status bs
335
+ SET session_count = (
336
+ SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = bs.bot_id
337
+ )`);
333
338
  spinner.succeed(`bots sync 완료: ${upserted}개 봇 업서트`);
334
339
  if (errors.length > 0) {
335
340
  errors.forEach(e => console.log(chalk_1.default.red(` ❌ ${e}`)));
@@ -0,0 +1,16 @@
1
+ /**
2
+ * semo sessions — 세션 추적
3
+ *
4
+ * Claude Code 훅(SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
5
+ * semo.bot_sessions 테이블에 upsert합니다.
6
+ *
7
+ * Claude Code hook stdin 구조:
8
+ * SessionStart: { session_id, transcript_path, cwd, hook_event_name }
9
+ * Stop: { session_id, transcript_path, hook_event_name }
10
+ *
11
+ * 사용법 (settings.json hooks):
12
+ * SessionStart → semo sessions push --bot-id workclaw --event start
13
+ * Stop → semo sessions push --bot-id workclaw --event stop
14
+ */
15
+ import { Command } from "commander";
16
+ export declare function registerSessionsCommands(program: Command): void;
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ /**
3
+ * semo sessions — 세션 추적
4
+ *
5
+ * Claude Code 훅(SessionStart / Stop)에서 stdin으로 전달되는 JSON을 파싱해
6
+ * semo.bot_sessions 테이블에 upsert합니다.
7
+ *
8
+ * Claude Code hook stdin 구조:
9
+ * SessionStart: { session_id, transcript_path, cwd, hook_event_name }
10
+ * Stop: { session_id, transcript_path, hook_event_name }
11
+ *
12
+ * 사용법 (settings.json hooks):
13
+ * SessionStart → semo sessions push --bot-id workclaw --event start
14
+ * Stop → semo sessions push --bot-id workclaw --event stop
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ var __importDefault = (this && this.__importDefault) || function (mod) {
50
+ return (mod && mod.__esModule) ? mod : { "default": mod };
51
+ };
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.registerSessionsCommands = registerSessionsCommands;
54
+ const chalk_1 = __importDefault(require("chalk"));
55
+ const fs = __importStar(require("fs"));
56
+ const path = __importStar(require("path"));
57
+ const readline = __importStar(require("readline"));
58
+ const child_process_1 = require("child_process");
59
+ const database_1 = require("../database");
60
+ async function readStdin() {
61
+ // stdin이 TTY면 hook에서 호출된 게 아님 → 빈 객체 반환
62
+ if (process.stdin.isTTY)
63
+ return {};
64
+ return new Promise((resolve) => {
65
+ let raw = "";
66
+ process.stdin.setEncoding("utf-8");
67
+ process.stdin.on("data", (chunk) => (raw += chunk));
68
+ process.stdin.on("end", () => {
69
+ try {
70
+ resolve(JSON.parse(raw));
71
+ }
72
+ catch {
73
+ resolve({});
74
+ }
75
+ });
76
+ // 500ms 타임아웃 — stdin이 오지 않으면 그냥 진행
77
+ setTimeout(() => resolve({}), 500);
78
+ });
79
+ }
80
+ // ─── 현재 git 브랜치 (label용) ───────────────────────────────────────────────
81
+ function getGitBranch(cwd) {
82
+ try {
83
+ const dir = cwd || process.cwd();
84
+ return (0, child_process_1.execSync)("git rev-parse --abbrev-ref HEAD", {
85
+ cwd: dir,
86
+ stdio: ["ignore", "pipe", "ignore"],
87
+ timeout: 2000,
88
+ })
89
+ .toString()
90
+ .trim();
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ // ─── transcript.jsonl 메시지 수 카운트 ───────────────────────────────────────
97
+ async function countMessages(transcriptPath) {
98
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
99
+ return 0;
100
+ return new Promise((resolve) => {
101
+ let count = 0;
102
+ const rl = readline.createInterface({
103
+ input: fs.createReadStream(transcriptPath),
104
+ crlfDelay: Infinity,
105
+ });
106
+ rl.on("line", (line) => {
107
+ if (!line.trim())
108
+ return;
109
+ try {
110
+ const obj = JSON.parse(line);
111
+ // role이 있는 메시지(user/assistant)만 카운트
112
+ if (obj.role === "user" || obj.role === "assistant")
113
+ count++;
114
+ }
115
+ catch {
116
+ // invalid line skip
117
+ }
118
+ });
119
+ rl.on("close", () => resolve(count));
120
+ rl.on("error", () => resolve(0));
121
+ });
122
+ }
123
+ // ─── Command registration ─────────────────────────────────────────────────────
124
+ function registerSessionsCommands(program) {
125
+ const sessionsCmd = program
126
+ .command("sessions")
127
+ .description("세션 추적 (Claude Code 훅 연동)");
128
+ // ── semo sessions push ───────────────────────────────────────────────────────
129
+ sessionsCmd
130
+ .command("push")
131
+ .description("현재 세션을 semo.bot_sessions에 기록 (훅에서 호출)")
132
+ .requiredOption("--bot-id <id>", "봇 ID (e.g. workclaw)")
133
+ .option("--event <type>", "이벤트 종류 (start|stop|heartbeat)", "heartbeat")
134
+ .option("--label <text>", "세션 라벨 (미지정 시 git 브랜치 자동 감지)")
135
+ .option("--kind <kind>", "세션 종류 (main|isolated)", "main")
136
+ .action(async (options) => {
137
+ const botId = options.botId;
138
+ const event = options.event;
139
+ // stdin에서 Claude Code hook JSON 읽기
140
+ const hook = await readStdin();
141
+ const sessionKey = hook.session_id ||
142
+ process.env.CLAUDE_SESSION_ID ||
143
+ `${botId}-${Date.now()}`;
144
+ const branch = getGitBranch(hook.cwd);
145
+ const label = options.label ||
146
+ branch ||
147
+ path.basename(hook.cwd || process.cwd());
148
+ const messageCount = event === "stop" && hook.transcript_path
149
+ ? await countMessages(hook.transcript_path)
150
+ : undefined;
151
+ const connected = await (0, database_1.isDbConnected)();
152
+ if (!connected) {
153
+ // 훅에서 호출 시 조용히 실패 (봇 세션에 영향 주지 않도록)
154
+ await (0, database_1.closeConnection)();
155
+ process.exit(0);
156
+ }
157
+ try {
158
+ const pool = (0, database_1.getPool)();
159
+ const client = await pool.connect();
160
+ if (event === "start") {
161
+ await client.query(`INSERT INTO semo.bot_sessions
162
+ (bot_id, session_key, label, kind, chat_type, last_activity, message_count, synced_at)
163
+ VALUES ($1, $2, $3, $4, 'claude-code', NOW(), 0, NOW())
164
+ ON CONFLICT (bot_id, session_key) DO UPDATE SET
165
+ label = EXCLUDED.label,
166
+ last_activity = NOW(),
167
+ synced_at = NOW()`, [botId, sessionKey, label, options.kind]);
168
+ // bot_status.session_count 갱신
169
+ await client.query(`UPDATE semo.bot_status
170
+ SET session_count = (
171
+ SELECT COUNT(*) FROM semo.bot_sessions WHERE bot_id = $1
172
+ ),
173
+ synced_at = NOW()
174
+ WHERE bot_id = $1`, [botId]);
175
+ }
176
+ else if (event === "stop") {
177
+ await client.query(`UPDATE semo.bot_sessions
178
+ SET last_activity = NOW(),
179
+ message_count = COALESCE($1, message_count),
180
+ synced_at = NOW()
181
+ WHERE bot_id = $2 AND session_key = $3`, [messageCount ?? null, botId, sessionKey]);
182
+ }
183
+ else {
184
+ // heartbeat — 마지막 활동 시간 + 메시지 수 갱신
185
+ await client.query(`INSERT INTO semo.bot_sessions
186
+ (bot_id, session_key, label, kind, chat_type, last_activity, message_count, synced_at)
187
+ VALUES ($1, $2, $3, $4, 'claude-code', NOW(), COALESCE($5, 0), NOW())
188
+ ON CONFLICT (bot_id, session_key) DO UPDATE SET
189
+ last_activity = NOW(),
190
+ message_count = COALESCE(EXCLUDED.message_count, semo.bot_sessions.message_count),
191
+ synced_at = NOW()`, [botId, sessionKey, label, options.kind, messageCount ?? null]);
192
+ }
193
+ client.release();
194
+ console.log(chalk_1.default.green(`✔ sessions push [${event}] ${botId}/${sessionKey.slice(0, 8)}`));
195
+ }
196
+ catch (err) {
197
+ // 훅에서 호출 시 조용히 실패
198
+ console.error(chalk_1.default.red(`sessions push 실패: ${err}`));
199
+ process.exit(0);
200
+ }
201
+ finally {
202
+ await (0, database_1.closeConnection)();
203
+ }
204
+ });
205
+ // ── semo sessions list ───────────────────────────────────────────────────────
206
+ sessionsCmd
207
+ .command("list")
208
+ .description("bot_sessions 테이블 조회")
209
+ .option("--bot-id <id>", "특정 봇만")
210
+ .option("--limit <n>", "최대 조회 수", "20")
211
+ .option("--format <type>", "출력 형식 (table|json)", "table")
212
+ .action(async (options) => {
213
+ const connected = await (0, database_1.isDbConnected)();
214
+ if (!connected) {
215
+ console.log(chalk_1.default.red("❌ DB 연결 실패"));
216
+ await (0, database_1.closeConnection)();
217
+ process.exit(1);
218
+ }
219
+ try {
220
+ const pool = (0, database_1.getPool)();
221
+ const client = await pool.connect();
222
+ const params = [];
223
+ let where = "";
224
+ if (options.botId) {
225
+ where = "WHERE bot_id = $1";
226
+ params.push(options.botId);
227
+ }
228
+ params.push(parseInt(options.limit));
229
+ const limitIdx = params.length;
230
+ const result = await client.query(`SELECT bot_id, session_key, label, kind, chat_type,
231
+ last_activity::text, message_count
232
+ FROM semo.bot_sessions
233
+ ${where}
234
+ ORDER BY last_activity DESC NULLS LAST
235
+ LIMIT $${limitIdx}`, params);
236
+ client.release();
237
+ if (options.format === "json") {
238
+ console.log(JSON.stringify(result.rows, null, 2));
239
+ }
240
+ else {
241
+ console.log(chalk_1.default.cyan.bold("\n📋 세션 목록\n"));
242
+ if (result.rows.length === 0) {
243
+ console.log(chalk_1.default.yellow(" 세션 없음"));
244
+ }
245
+ else {
246
+ for (const s of result.rows) {
247
+ const ts = s.last_activity
248
+ ? new Date(s.last_activity).toLocaleString("ko-KR")
249
+ : "-";
250
+ console.log(chalk_1.default.cyan(` ${s.bot_id.padEnd(14)}`) +
251
+ chalk_1.default.white(`${(s.label || s.session_key).padEnd(30)}`) +
252
+ chalk_1.default.gray(`${ts} ${s.message_count}msg`));
253
+ }
254
+ }
255
+ console.log();
256
+ }
257
+ }
258
+ catch (err) {
259
+ console.log(chalk_1.default.red(`❌ 조회 실패: ${err}`));
260
+ process.exit(1);
261
+ }
262
+ finally {
263
+ await (0, database_1.closeConnection)();
264
+ }
265
+ });
266
+ }
package/dist/index.js CHANGED
@@ -60,6 +60,7 @@ const database_1 = require("./database");
60
60
  const context_1 = require("./commands/context");
61
61
  const bots_1 = require("./commands/bots");
62
62
  const get_1 = require("./commands/get");
63
+ const sessions_1 = require("./commands/sessions");
63
64
  const PACKAGE_NAME = "@team-semicolon/semo-cli";
64
65
  // package.json에서 버전 동적 로드
65
66
  function getCliVersion() {
@@ -3001,6 +3002,7 @@ ontoCmd
3001
3002
  (0, context_1.registerContextCommands)(program);
3002
3003
  (0, bots_1.registerBotsCommands)(program);
3003
3004
  (0, get_1.registerGetCommands)(program);
3005
+ (0, sessions_1.registerSessionsCommands)(program);
3004
3006
  // === semo skills — DB 시딩 ===
3005
3007
  /**
3006
3008
  * SKILL.md frontmatter 파싱 (YAML 파서 없이 regex 기반)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {