@triflux/core 10.50.2 → 10.52.0

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 (84) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +65 -1250
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +3 -19
  8. package/hub/lib/process-utils.mjs +1 -156
  9. package/hub/lib/spawn-trace.mjs +3 -2
  10. package/hub/lib/timeout-defaults.mjs +0 -1
  11. package/hub/team/claude-daemon-control.mjs +1 -2
  12. package/hub/team/claude-session-projection.mjs +5 -73
  13. package/hub/team/nativeProxy.mjs +759 -0
  14. package/hub/team/session-context.mjs +1 -1
  15. package/hud/cli-policy.mjs +0 -22
  16. package/hud/constants.mjs +13 -26
  17. package/hud/hud-qos-status.mjs +42 -54
  18. package/hud/providers/gemini.mjs +125 -20
  19. package/hud/renderers.mjs +101 -122
  20. package/hud/utils.mjs +10 -6
  21. package/package.json +2 -6
  22. package/scripts/lib/agent-route-policy.mjs +143 -62
  23. package/scripts/lib/cli-claude.mjs +0 -14
  24. package/scripts/lib/env-probe.mjs +2 -131
  25. package/scripts/lib/gemini-profiles.mjs +2 -69
  26. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  27. package/scripts/lib/machine-profile.mjs +3 -17
  28. package/scripts/lib/mcp-filter.mjs +109 -53
  29. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  30. package/scripts/lib/mcp-health.mjs +2 -2
  31. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  32. package/scripts/lib/psmux-info.mjs +11 -59
  33. package/scripts/lib/session-presence.mjs +2 -100
  34. package/scripts/lib/skill-template.mjs +0 -245
  35. package/hub/adaptive-diagnostic.mjs +0 -350
  36. package/hub/adaptive-inject.mjs +0 -224
  37. package/hub/adaptive-memory.mjs +0 -277
  38. package/hub/adaptive.mjs +0 -172
  39. package/hub/assign-callbacks.mjs +0 -170
  40. package/hub/delegator/contracts.mjs +0 -38
  41. package/hub/delegator/index.mjs +0 -14
  42. package/hub/delegator/schema/delegator-tools.schema.json +0 -250
  43. package/hub/delegator/service.mjs +0 -336
  44. package/hub/delegator/tool-definitions.mjs +0 -35
  45. package/hub/dynamic-routing-engine.mjs +0 -511
  46. package/hub/hitl.mjs +0 -202
  47. package/hub/lib/known-errors.json +0 -72
  48. package/hub/lib/memory-store.mjs +0 -1001
  49. package/hub/lib/phase-manager.mjs +0 -493
  50. package/hub/lib/risk-tier.mjs +0 -53
  51. package/hub/lib/ssh-command.mjs +0 -231
  52. package/hub/lib/staged-file-detect.mjs +0 -43
  53. package/hub/lib/state-snapshot.mjs +0 -293
  54. package/hub/lib/tfx-route-args.mjs +0 -353
  55. package/hub/lib/trace-recorder.mjs +0 -153
  56. package/hub/lib/uuidv7.mjs +0 -45
  57. package/hub/memory-doctor.mjs +0 -640
  58. package/hub/middleware/quota-middleware.mjs +0 -70
  59. package/hub/middleware/request-logger.mjs +0 -191
  60. package/hub/paths.mjs +0 -24
  61. package/hub/pipeline/index.mjs +0 -163
  62. package/hub/pipeline/state.mjs +0 -191
  63. package/hub/pipeline/transitions.mjs +0 -131
  64. package/hub/reflexion.mjs +0 -290
  65. package/hub/role-contract.mjs +0 -314
  66. package/hub/router.mjs +0 -2026
  67. package/hub/routing-snapshot.mjs +0 -261
  68. package/hub/session-fingerprint.mjs +0 -418
  69. package/hub/state.mjs +0 -300
  70. package/hub/team/synapse-http.mjs +0 -311
  71. package/hub/team-bridge.mjs +0 -27
  72. package/mesh/index.mjs +0 -69
  73. package/mesh/mesh-budget.mjs +0 -137
  74. package/mesh/mesh-heartbeat.mjs +0 -100
  75. package/mesh/mesh-protocol.mjs +0 -96
  76. package/mesh/mesh-queue.mjs +0 -163
  77. package/mesh/mesh-registry.mjs +0 -78
  78. package/mesh/mesh-router.mjs +0 -77
  79. package/scripts/lib/claudemd-scanner.mjs +0 -223
  80. package/scripts/lib/context.mjs +0 -67
  81. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  82. package/scripts/lib/handoff.mjs +0 -187
  83. package/scripts/lib/logger.mjs +0 -122
  84. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -0,0 +1,759 @@
1
+ // hub/team/nativeProxy.mjs
2
+ // Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import {
14
+ open as openFile,
15
+ readdir,
16
+ readFile,
17
+ stat,
18
+ unlink as unlinkFile,
19
+ } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { basename, dirname, join } from "node:path";
22
+ import { isPidAlive } from "../lib/process-utils.mjs";
23
+ import { IS_WINDOWS } from "../platform.mjs";
24
+
25
+ const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
26
+ const CLAUDE_HOME = join(homedir(), ".claude");
27
+ const TEAMS_ROOT = join(CLAUDE_HOME, "teams");
28
+ const TASKS_ROOT = join(CLAUDE_HOME, "tasks");
29
+ const LOCK_STALE_MS = 30000;
30
+
31
+ // ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
32
+ const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
33
+ const _taskIdIndex = new Map(); // taskId → filePath
34
+ const _taskContentCache = new Map(); // filePath → { mtimeMs, data }
35
+
36
+ function _invalidateCache(tasksDir) {
37
+ _dirCache.delete(tasksDir);
38
+ }
39
+
40
+ function err(code, message, extra = {}) {
41
+ return { ok: false, error: { code, message, ...extra } };
42
+ }
43
+
44
+ function validateTeamName(teamName) {
45
+ if (!TEAM_NAME_RE.test(String(teamName || ""))) {
46
+ throw new Error("INVALID_TEAM_NAME");
47
+ }
48
+ }
49
+
50
+ async function readJsonSafe(path) {
51
+ try {
52
+ return JSON.parse(await readFile(path, "utf8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function atomicWriteJson(path, value) {
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
61
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
62
+ try {
63
+ renameSync(tmp, path);
64
+ } catch (e) {
65
+ // Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
66
+ if (IS_WINDOWS && (e.code === "EPERM" || e.code === "EEXIST")) {
67
+ try {
68
+ unlinkSync(path);
69
+ } catch {}
70
+ renameSync(tmp, path);
71
+ } else {
72
+ try {
73
+ unlinkSync(tmp);
74
+ } catch {}
75
+ throw e;
76
+ }
77
+ }
78
+ }
79
+
80
+ async function sleepMs(ms) {
81
+ return new Promise((r) => setTimeout(r, ms));
82
+ }
83
+
84
+ async function readLockInfo(lockPath) {
85
+ let lockStat;
86
+ try {
87
+ lockStat = await stat(lockPath);
88
+ } catch {
89
+ return null;
90
+ }
91
+
92
+ let parsed = null;
93
+ try {
94
+ parsed = JSON.parse(await readFile(lockPath, "utf8"));
95
+ } catch {}
96
+
97
+ const now = Date.now();
98
+ const createdAtMs = Number(
99
+ parsed?.created_at_ms ??
100
+ parsed?.timestamp_ms ??
101
+ parsed?.timestamp ??
102
+ lockStat.mtimeMs,
103
+ );
104
+ const pid = Number(parsed?.pid);
105
+
106
+ return {
107
+ token: typeof parsed?.token === "string" ? parsed.token : null,
108
+ pid: Number.isInteger(pid) && pid > 0 ? pid : null,
109
+ created_at_ms: Number.isFinite(createdAtMs)
110
+ ? createdAtMs
111
+ : lockStat.mtimeMs,
112
+ mtime_ms: lockStat.mtimeMs,
113
+ age_ms: Math.max(
114
+ 0,
115
+ now - (Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs),
116
+ ),
117
+ };
118
+ }
119
+
120
+ async function releaseFileLock(lockPath, token, handle) {
121
+ try {
122
+ await handle?.close();
123
+ } catch {}
124
+
125
+ try {
126
+ const current = await readLockInfo(lockPath);
127
+ if (!current || current.token === token) {
128
+ await unlinkFile(lockPath);
129
+ }
130
+ } catch {}
131
+ }
132
+
133
+ async function withFileLock(
134
+ lockPath,
135
+ fn,
136
+ retries = 20,
137
+ delayMs = 25,
138
+ staleMs = LOCK_STALE_MS,
139
+ ) {
140
+ mkdirSync(dirname(lockPath), { recursive: true });
141
+ const lockOwner = {
142
+ pid: process.pid,
143
+ token: randomUUID(),
144
+ created_at: new Date().toISOString(),
145
+ created_at_ms: Date.now(),
146
+ };
147
+ let handle = null;
148
+ let lastError = null;
149
+
150
+ for (let i = 0; i < retries; i += 1) {
151
+ try {
152
+ handle = await openFile(lockPath, "wx");
153
+ try {
154
+ await handle.writeFile(`${JSON.stringify(lockOwner)}\n`, "utf8");
155
+ } catch (writeError) {
156
+ await releaseFileLock(lockPath, lockOwner.token, handle);
157
+ throw writeError;
158
+ }
159
+ break;
160
+ } catch (e) {
161
+ lastError = e;
162
+ if (e?.code !== "EEXIST") throw e;
163
+
164
+ const current = await readLockInfo(lockPath);
165
+ const staleByAge = !current || current.age_ms > staleMs;
166
+ const staleByDeadPid = current?.pid != null && !isPidAlive(current.pid);
167
+ if (staleByAge || staleByDeadPid) {
168
+ try {
169
+ await unlinkFile(lockPath);
170
+ continue;
171
+ } catch (unlinkError) {
172
+ if (unlinkError?.code === "ENOENT") continue;
173
+ lastError = unlinkError;
174
+ }
175
+ }
176
+
177
+ if (i === retries - 1) throw e;
178
+ await sleepMs(delayMs);
179
+ }
180
+ }
181
+
182
+ if (!handle) {
183
+ throw lastError || new Error(`LOCK_NOT_ACQUIRED: ${lockPath}`);
184
+ }
185
+
186
+ try {
187
+ return await fn();
188
+ } finally {
189
+ await releaseFileLock(lockPath, lockOwner.token, handle);
190
+ }
191
+ }
192
+
193
+ function getLeadSessionId(config) {
194
+ return (
195
+ config?.leadSessionId ||
196
+ config?.lead_session_id ||
197
+ config?.lead?.lead_session_id ||
198
+ config?.lead?.sessionId ||
199
+ null
200
+ );
201
+ }
202
+
203
+ export async function resolveTeamPaths(teamName) {
204
+ validateTeamName(teamName);
205
+
206
+ const teamDir = join(TEAMS_ROOT, teamName);
207
+ const configPath = join(teamDir, "config.json");
208
+ const inboxesDir = join(teamDir, "inboxes");
209
+ const config = await readJsonSafe(configPath);
210
+ const leadSessionId = getLeadSessionId(config);
211
+
212
+ const byTeam = join(TASKS_ROOT, teamName);
213
+ const byLeadSession = leadSessionId ? join(TASKS_ROOT, leadSessionId) : null;
214
+
215
+ let tasksDir = byTeam;
216
+ let tasksDirResolution = "not_found";
217
+ if (existsSync(byTeam)) {
218
+ tasksDirResolution = "team_name";
219
+ } else if (byLeadSession && existsSync(byLeadSession)) {
220
+ tasksDir = byLeadSession;
221
+ tasksDirResolution = "lead_session_id";
222
+ }
223
+
224
+ return {
225
+ team_dir: teamDir,
226
+ config_path: configPath,
227
+ inboxes_dir: inboxesDir,
228
+ tasks_dir: tasksDir,
229
+ tasks_dir_resolution: tasksDirResolution,
230
+ lead_session_id: leadSessionId,
231
+ config,
232
+ };
233
+ }
234
+
235
+ export async function forceCleanupTeam(teamName) {
236
+ validateTeamName(teamName);
237
+
238
+ let paths;
239
+ try {
240
+ paths = await resolveTeamPaths(teamName);
241
+ } catch {
242
+ paths = {
243
+ team_dir: join(TEAMS_ROOT, teamName),
244
+ config_path: join(TEAMS_ROOT, teamName, "config.json"),
245
+ tasks_dir: join(TASKS_ROOT, teamName),
246
+ lead_session_id: null,
247
+ config: null,
248
+ };
249
+ }
250
+
251
+ try {
252
+ const config = paths.config || (await readJsonSafe(paths.config_path));
253
+ if (config && Array.isArray(config.members)) {
254
+ atomicWriteJson(paths.config_path, {
255
+ ...config,
256
+ members: config.members.map((member) => ({
257
+ ...member,
258
+ isActive: false,
259
+ })),
260
+ });
261
+ }
262
+ } catch {}
263
+
264
+ const cleanupTargets = new Set([
265
+ paths.team_dir,
266
+ join(TASKS_ROOT, teamName),
267
+ paths.tasks_dir,
268
+ ]);
269
+ if (paths.lead_session_id) {
270
+ cleanupTargets.add(join(TASKS_ROOT, paths.lead_session_id));
271
+ }
272
+
273
+ for (const targetPath of cleanupTargets) {
274
+ if (!targetPath) continue;
275
+ try {
276
+ rmSync(targetPath, { recursive: true, force: true });
277
+ } catch {}
278
+ }
279
+ }
280
+
281
+ async function collectTaskFiles(tasksDir) {
282
+ if (!existsSync(tasksDir)) return [];
283
+
284
+ // 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
285
+ let dirMtime;
286
+ try {
287
+ dirMtime = (await stat(tasksDir)).mtimeMs;
288
+ } catch {
289
+ return [];
290
+ }
291
+
292
+ const cached = _dirCache.get(tasksDir);
293
+ if (cached && cached.mtimeMs === dirMtime) {
294
+ return cached.files;
295
+ }
296
+
297
+ const entries = await readdir(tasksDir);
298
+ const files = entries
299
+ .filter((name) => name.endsWith(".json"))
300
+ .filter((name) => !name.endsWith(".lock"))
301
+ .filter((name) => name !== ".highwatermark")
302
+ .map((name) => join(tasksDir, name));
303
+
304
+ _dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
305
+ return files;
306
+ }
307
+
308
+ async function readTaskFileCached(file) {
309
+ let fileMtime;
310
+ try {
311
+ fileMtime = (await stat(file)).mtimeMs;
312
+ } catch {
313
+ return { file, mtimeMs: null, json: null };
314
+ }
315
+
316
+ const contentCached = _taskContentCache.get(file);
317
+ if (contentCached && contentCached.mtimeMs === fileMtime) {
318
+ return { file, mtimeMs: fileMtime, json: contentCached.data };
319
+ }
320
+
321
+ const json = await readJsonSafe(file);
322
+ if (json && isObject(json)) {
323
+ _taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
324
+ }
325
+
326
+ return { file, mtimeMs: fileMtime, json };
327
+ }
328
+
329
+ async function locateTaskFile(tasksDir, taskId) {
330
+ const direct = join(tasksDir, `${taskId}.json`);
331
+ if (existsSync(direct)) return direct;
332
+
333
+ // ID→파일 인덱스 캐시
334
+ const indexed = _taskIdIndex.get(taskId);
335
+ if (indexed && existsSync(indexed)) return indexed;
336
+
337
+ // 캐시된 collectTaskFiles로 풀 스캔
338
+ const files = await collectTaskFiles(tasksDir);
339
+ for (const file of files) {
340
+ if (basename(file, ".json") === taskId) {
341
+ _taskIdIndex.set(taskId, file);
342
+ return file;
343
+ }
344
+ const json = await readJsonSafe(file);
345
+ if (json && String(json.id || "") === taskId) {
346
+ _taskIdIndex.set(taskId, file);
347
+ return file;
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function isObject(v) {
354
+ return !!v && typeof v === "object" && !Array.isArray(v);
355
+ }
356
+
357
+ export async function teamInfo(args = {}) {
358
+ const { team_name, include_members = true, include_paths = true } = args;
359
+ try {
360
+ validateTeamName(team_name);
361
+ } catch {
362
+ return err("INVALID_TEAM_NAME", "team_name 형식이 올바르지 않습니다");
363
+ }
364
+
365
+ const paths = await resolveTeamPaths(team_name);
366
+ if (!existsSync(paths.team_dir)) {
367
+ return err("TEAM_NOT_FOUND", `팀 디렉토리가 없습니다: ${paths.team_dir}`);
368
+ }
369
+
370
+ const members = Array.isArray(paths.config?.members)
371
+ ? paths.config.members
372
+ : [];
373
+ const leadAgentId =
374
+ paths.config?.leadAgentId ||
375
+ paths.config?.lead_agent_id ||
376
+ members[0]?.agentId ||
377
+ null;
378
+
379
+ return {
380
+ ok: true,
381
+ data: {
382
+ team: {
383
+ team_name,
384
+ description: paths.config?.description || null,
385
+ },
386
+ lead: {
387
+ lead_agent_id: leadAgentId,
388
+ lead_session_id: paths.lead_session_id,
389
+ },
390
+ ...(include_members ? { members } : {}),
391
+ ...(include_paths
392
+ ? {
393
+ paths: {
394
+ config_path: paths.config_path,
395
+ tasks_dir: paths.tasks_dir,
396
+ inboxes_dir: paths.inboxes_dir,
397
+ tasks_dir_resolution: paths.tasks_dir_resolution,
398
+ },
399
+ }
400
+ : {}),
401
+ },
402
+ };
403
+ }
404
+
405
+ export async function teamTaskList(args = {}) {
406
+ const {
407
+ team_name,
408
+ owner,
409
+ statuses = [],
410
+ include_internal = false,
411
+ limit = 200,
412
+ } = args;
413
+
414
+ try {
415
+ validateTeamName(team_name);
416
+ } catch {
417
+ return err("INVALID_TEAM_NAME", "team_name 형식이 올바르지 않습니다");
418
+ }
419
+
420
+ const paths = await resolveTeamPaths(team_name);
421
+ if (paths.tasks_dir_resolution === "not_found") {
422
+ return err(
423
+ "TASKS_DIR_NOT_FOUND",
424
+ `task 디렉토리를 찾지 못했습니다: ${team_name}`,
425
+ );
426
+ }
427
+
428
+ const statusSet = new Set((statuses || []).map((s) => String(s)));
429
+ const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
430
+ let parseWarnings = 0;
431
+ const files = await collectTaskFiles(paths.tasks_dir);
432
+ const records = await Promise.all(
433
+ files.map((file) => readTaskFileCached(file)),
434
+ );
435
+
436
+ const tasks = [];
437
+ for (const { file, mtimeMs: fileMtime, json } of records) {
438
+ if (!json || !isObject(json)) {
439
+ parseWarnings += 1;
440
+ continue;
441
+ }
442
+
443
+ if (!include_internal && json?.metadata?._internal === true) continue;
444
+ if (owner && String(json.owner || "") !== String(owner)) continue;
445
+ if (statusSet.size > 0 && !statusSet.has(String(json.status || "")))
446
+ continue;
447
+
448
+ tasks.push({
449
+ ...json,
450
+ task_file: file,
451
+ mtime_ms: fileMtime,
452
+ });
453
+ }
454
+
455
+ tasks.sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0));
456
+ const sliced = tasks.slice(0, maxCount);
457
+
458
+ return {
459
+ ok: true,
460
+ data: {
461
+ tasks: sliced,
462
+ count: sliced.length,
463
+ parse_warnings: parseWarnings,
464
+ tasks_dir: paths.tasks_dir,
465
+ tasks_dir_resolution: paths.tasks_dir_resolution,
466
+ },
467
+ };
468
+ }
469
+
470
+ // status 화이트리스트 (Claude Code API 호환)
471
+ const VALID_STATUSES = new Set([
472
+ "pending",
473
+ "in_progress",
474
+ "completed",
475
+ "deleted",
476
+ ]);
477
+
478
+ export async function teamTaskUpdate(args = {}) {
479
+ // "failed" → "completed" + metadata.result 자동 매핑
480
+ if (String(args.status || "") === "failed") {
481
+ args = {
482
+ ...args,
483
+ status: "completed",
484
+ metadata_patch: { ...(args.metadata_patch || {}), result: "failed" },
485
+ };
486
+ } else if (args.status != null && !VALID_STATUSES.has(String(args.status))) {
487
+ return err(
488
+ "INVALID_STATUS",
489
+ `유효하지 않은 status: ${args.status}. 허용: ${[...VALID_STATUSES].join(", ")}`,
490
+ );
491
+ }
492
+
493
+ const {
494
+ team_name,
495
+ task_id,
496
+ claim = false,
497
+ owner,
498
+ status,
499
+ subject,
500
+ description,
501
+ activeForm,
502
+ add_blocks = [],
503
+ add_blocked_by = [],
504
+ metadata_patch,
505
+ if_match_mtime_ms,
506
+ actor,
507
+ } = args;
508
+
509
+ try {
510
+ validateTeamName(team_name);
511
+ } catch {
512
+ return err("INVALID_TEAM_NAME", "team_name 형식이 올바르지 않습니다");
513
+ }
514
+
515
+ if (!String(task_id || "").trim()) {
516
+ return err("INVALID_TASK_ID", "task_id가 필요합니다");
517
+ }
518
+
519
+ const paths = await resolveTeamPaths(team_name);
520
+ if (paths.tasks_dir_resolution === "not_found") {
521
+ return err(
522
+ "TASKS_DIR_NOT_FOUND",
523
+ `task 디렉토리를 찾지 못했습니다: ${team_name}`,
524
+ );
525
+ }
526
+
527
+ const taskFile = await locateTaskFile(paths.tasks_dir, String(task_id));
528
+ if (!taskFile) {
529
+ return err("TASK_NOT_FOUND", `task를 찾지 못했습니다: ${task_id}`);
530
+ }
531
+
532
+ const lockFile = `${taskFile}.lock`;
533
+
534
+ try {
535
+ return await withFileLock(lockFile, async () => {
536
+ const before = await readJsonSafe(taskFile);
537
+ if (!before || !isObject(before)) {
538
+ return err("INVALID_TASK_FILE", `task 파일 파싱 실패: ${taskFile}`);
539
+ }
540
+
541
+ let beforeMtime = Date.now();
542
+ try {
543
+ beforeMtime = (await stat(taskFile)).mtimeMs;
544
+ } catch {}
545
+
546
+ if (
547
+ if_match_mtime_ms != null &&
548
+ Number(if_match_mtime_ms) !== Number(beforeMtime)
549
+ ) {
550
+ return err("MTIME_CONFLICT", "if_match_mtime_ms가 일치하지 않습니다", {
551
+ task_file: taskFile,
552
+ mtime_ms: beforeMtime,
553
+ });
554
+ }
555
+
556
+ const after = JSON.parse(JSON.stringify(before));
557
+ let claimed = false;
558
+ let updated = false;
559
+
560
+ if (claim) {
561
+ const requestedOwner = String(owner || actor || "");
562
+ const ownerNow = String(before.owner || "");
563
+ const ownerCompatible =
564
+ ownerNow === "" ||
565
+ requestedOwner === "" ||
566
+ ownerNow === requestedOwner;
567
+ const statusPending = String(before.status || "") === "pending";
568
+
569
+ if (!statusPending || !ownerCompatible) {
570
+ return err("CLAIM_CONFLICT", "task claim 충돌", {
571
+ task_before: before,
572
+ task_file: taskFile,
573
+ mtime_ms: beforeMtime,
574
+ });
575
+ }
576
+
577
+ if (requestedOwner) after.owner = requestedOwner;
578
+ after.status = status || "in_progress";
579
+ claimed = true;
580
+ updated = true;
581
+ }
582
+
583
+ if (owner != null && String(after.owner || "") !== String(owner)) {
584
+ after.owner = owner;
585
+ updated = true;
586
+ }
587
+ if (status != null && String(after.status || "") !== String(status)) {
588
+ after.status = status;
589
+ updated = true;
590
+ }
591
+ if (subject != null && String(after.subject || "") !== String(subject)) {
592
+ after.subject = subject;
593
+ updated = true;
594
+ }
595
+ if (
596
+ description != null &&
597
+ String(after.description || "") !== String(description)
598
+ ) {
599
+ after.description = description;
600
+ updated = true;
601
+ }
602
+ if (
603
+ activeForm != null &&
604
+ String(after.activeForm || "") !== String(activeForm)
605
+ ) {
606
+ after.activeForm = activeForm;
607
+ updated = true;
608
+ }
609
+
610
+ if (Array.isArray(add_blocks) && add_blocks.length > 0) {
611
+ const current = Array.isArray(after.blocks) ? [...after.blocks] : [];
612
+ for (const item of add_blocks) {
613
+ if (!current.includes(item)) current.push(item);
614
+ }
615
+ after.blocks = current;
616
+ updated = true;
617
+ }
618
+
619
+ if (Array.isArray(add_blocked_by) && add_blocked_by.length > 0) {
620
+ const current = Array.isArray(after.blockedBy)
621
+ ? [...after.blockedBy]
622
+ : [];
623
+ for (const item of add_blocked_by) {
624
+ if (!current.includes(item)) current.push(item);
625
+ }
626
+ after.blockedBy = current;
627
+ updated = true;
628
+ }
629
+
630
+ if (isObject(metadata_patch)) {
631
+ const base = isObject(after.metadata) ? after.metadata : {};
632
+ after.metadata = { ...base, ...metadata_patch };
633
+ updated = true;
634
+ }
635
+
636
+ if (updated) {
637
+ atomicWriteJson(taskFile, after);
638
+ _invalidateCache(dirname(taskFile));
639
+ // 콘텐츠 캐시 무효화
640
+ _taskContentCache.delete(taskFile);
641
+ }
642
+
643
+ let afterMtime = beforeMtime;
644
+ try {
645
+ afterMtime = (await stat(taskFile)).mtimeMs;
646
+ } catch {}
647
+
648
+ return {
649
+ ok: true,
650
+ data: {
651
+ claimed,
652
+ updated,
653
+ task_before: before,
654
+ task_after: updated ? after : before,
655
+ task_file: taskFile,
656
+ mtime_ms: afterMtime,
657
+ },
658
+ };
659
+ });
660
+ } catch (e) {
661
+ return err("TASK_UPDATE_FAILED", e.message);
662
+ }
663
+ }
664
+
665
+ function sanitizeRecipientName(v) {
666
+ return String(v || "team-lead").replace(/[\\/:*?"<>|]/g, "_");
667
+ }
668
+
669
+ export async function teamSendMessage(args = {}) {
670
+ const {
671
+ team_name,
672
+ from,
673
+ to = "team-lead",
674
+ text,
675
+ summary,
676
+ color = "blue",
677
+ } = args;
678
+
679
+ try {
680
+ validateTeamName(team_name);
681
+ } catch {
682
+ return err("INVALID_TEAM_NAME", "team_name 형식이 올바르지 않습니다");
683
+ }
684
+
685
+ if (!String(from || "").trim())
686
+ return err("INVALID_FROM", "from이 필요합니다");
687
+ if (!String(text || "").trim())
688
+ return err("INVALID_TEXT", "text가 필요합니다");
689
+
690
+ const paths = await resolveTeamPaths(team_name);
691
+ if (!existsSync(paths.team_dir)) {
692
+ return err("TEAM_NOT_FOUND", `팀 디렉토리가 없습니다: ${paths.team_dir}`);
693
+ }
694
+
695
+ const recipient = sanitizeRecipientName(to);
696
+ const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
697
+ const lockFile = `${inboxFile}.lock`;
698
+ let message;
699
+
700
+ try {
701
+ const unreadCount = await withFileLock(lockFile, async () => {
702
+ const queue = await readJsonSafe(inboxFile);
703
+ const list = Array.isArray(queue) ? queue : [];
704
+
705
+ message = {
706
+ id: randomUUID(),
707
+ from: String(from),
708
+ text: String(text),
709
+ ...(summary ? { summary: String(summary) } : {}),
710
+ timestamp: new Date().toISOString(),
711
+ color: String(color || "blue"),
712
+ read: false,
713
+ };
714
+ list.push(message);
715
+
716
+ // inbox 정리: 최대 200개 유지, read + 1시간 경과 메시지 제거
717
+ const MAX_INBOX = 200;
718
+ if (list.length > MAX_INBOX) {
719
+ const ONE_HOUR_MS = 3600000;
720
+ const cutoff = Date.now() - ONE_HOUR_MS;
721
+ const pruned = list.filter(
722
+ (m) =>
723
+ m?.read !== true ||
724
+ !m?.timestamp ||
725
+ new Date(m.timestamp).getTime() > cutoff,
726
+ );
727
+ list.length = 0;
728
+ list.push(...pruned);
729
+ if (list.length > MAX_INBOX) {
730
+ list.splice(0, list.length - MAX_INBOX);
731
+ }
732
+ }
733
+
734
+ atomicWriteJson(inboxFile, list);
735
+
736
+ return list.filter((m) => m?.read !== true).length;
737
+ });
738
+
739
+ return {
740
+ ok: true,
741
+ data: {
742
+ message_id: message.id,
743
+ recipient,
744
+ inbox_file: inboxFile,
745
+ queued_at: message.timestamp,
746
+ unread_count: unreadCount,
747
+ },
748
+ };
749
+ } catch (e) {
750
+ return err("SEND_MESSAGE_FAILED", e.message);
751
+ }
752
+ }
753
+
754
+ export const nativeProxy = Object.freeze({
755
+ teamInfo,
756
+ teamTaskList,
757
+ teamTaskUpdate,
758
+ teamSendMessage,
759
+ });