@triflux/remote 10.44.0 → 10.46.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 (40) hide show
  1. package/hub/pipe.mjs +10 -15
  2. package/hub/schema.sql +1 -0
  3. package/hub/server.mjs +172 -93
  4. package/hub/store.mjs +127 -61
  5. package/hub/team/backend.mjs +4 -2
  6. package/hub/team/claude-daemon-control.mjs +2 -0
  7. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  8. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  9. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  10. package/hub/team/conductor.mjs +80 -24
  11. package/hub/team/cto-auto-collect.mjs +2 -6
  12. package/hub/team/cto-pull.mjs +4 -0
  13. package/hub/team/dashboard-open.mjs +1 -0
  14. package/hub/team/execution-mode.mjs +38 -2
  15. package/hub/team/headless.mjs +493 -82
  16. package/hub/team/interactive-tui-transport.mjs +15 -1
  17. package/hub/team/launcher-template.mjs +17 -1
  18. package/hub/team/orchestrator.mjs +36 -9
  19. package/hub/team/pane.mjs +176 -14
  20. package/hub/team/psmux.mjs +79 -70
  21. package/hub/team/retry-state-machine.mjs +47 -16
  22. package/hub/team/session.mjs +57 -0
  23. package/hub/team/synapse-http.mjs +149 -4
  24. package/hub/team/terminal-opener.mjs +68 -13
  25. package/hub/team/tui-viewer.mjs +65 -14
  26. package/hub/team/uds-orchestrator.mjs +384 -83
  27. package/hub/tools.mjs +10 -1
  28. package/hub/workers/codex-mcp.mjs +9 -4
  29. package/hub/workers/delegator-mcp.mjs +1 -1
  30. package/package.json +1 -1
  31. package/scripts/lib/agent-route-policy.mjs +99 -38
  32. package/scripts/lib/cli-agy.mjs +24 -4
  33. package/scripts/lib/cli-codex.mjs +2 -0
  34. package/scripts/lib/codex-profile-config.mjs +45 -13
  35. package/scripts/lib/codex-recovery.sh +19 -0
  36. package/scripts/lib/gemini-profiles.mjs +113 -29
  37. package/scripts/lib/keyword-rules.mjs +12 -0
  38. package/scripts/lib/logger.mjs +60 -43
  39. package/scripts/lib/machine-profile.mjs +293 -0
  40. package/scripts/lib/mcp-health.mjs +44 -18
package/hub/store.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // hub/store.mjs — SQLite 감사 로그/메타데이터/역할 레지스트리 저장소
2
2
  // 실시간 배달은 router/pipe가 담당하고, 역할 권한과 복구 상태는 SQLite가 보존한다.
3
3
 
4
+ import { createHash } from "node:crypto";
4
5
  import { mkdirSync, readFileSync } from "node:fs";
5
6
  import { createRequire } from "node:module";
6
7
  import { dirname, join } from "node:path";
@@ -12,10 +13,7 @@ import {
12
13
  } from "@triflux/core/hub/lib/timeout-defaults.mjs";
13
14
  import { uuidv7 } from "@triflux/core/hub/lib/uuidv7.mjs";
14
15
  import { recalcConfidence } from "@triflux/core/hub/reflexion.mjs";
15
- import {
16
- decodeRoleKey,
17
- ROLE_REACHABILITY_STATES,
18
- } from "@triflux/core/hub/role-contract.mjs";
16
+ import { decodeRoleKey, ROLE_REACHABILITY_STATES } from "@triflux/core/hub/role-contract.mjs";
19
17
 
20
18
  export { uuidv7 };
21
19
 
@@ -122,6 +120,21 @@ function ensureV6Columns(db) {
122
120
  }
123
121
  }
124
122
 
123
+ function ensureV7Columns(db) {
124
+ if (hasTable(db, "agents")) {
125
+ ensureColumn(
126
+ db,
127
+ "agents",
128
+ "presence_generation",
129
+ "INTEGER NOT NULL DEFAULT 0",
130
+ );
131
+ }
132
+ }
133
+
134
+ function storeFingerprint(dbPath) {
135
+ return `sqlite:${createHash("sha256").update(String(dbPath)).digest("hex")}`;
136
+ }
137
+
125
138
  /**
126
139
  * 저장소 생성
127
140
  * @param {string} dbPath
@@ -152,7 +165,7 @@ export function createStore(dbPath, options = {}) {
152
165
  db.exec(
153
166
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
154
167
  );
155
- const SCHEMA_VERSION = "6";
168
+ const SCHEMA_VERSION = "7";
156
169
  const curVer = (() => {
157
170
  try {
158
171
  return db
@@ -169,6 +182,7 @@ export function createStore(dbPath, options = {}) {
169
182
  // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
170
183
  // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
171
184
  ensureV6Columns(db);
185
+ ensureV7Columns(db);
172
186
  if (curVer !== SCHEMA_VERSION) {
173
187
  if (curVer != null) {
174
188
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -183,6 +197,7 @@ export function createStore(dbPath, options = {}) {
183
197
  }
184
198
  // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
185
199
  ensureV6Columns(db);
200
+ ensureV7Columns(db);
186
201
  ensureColumn(
187
202
  db,
188
203
  "reflexion_entries",
@@ -199,8 +214,8 @@ export function createStore(dbPath, options = {}) {
199
214
 
200
215
  const S = {
201
216
  upsertAgent: db.prepare(`
202
- INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
203
- VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
217
+ INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, presence_generation, status, metadata_json)
218
+ VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, 1, @status, @metadata_json)
204
219
  ON CONFLICT(agent_id) DO UPDATE SET
205
220
  cli=excluded.cli,
206
221
  pid=excluded.pid,
@@ -208,6 +223,7 @@ export function createStore(dbPath, options = {}) {
208
223
  topics_json=excluded.topics_json,
209
224
  last_seen_ms=excluded.last_seen_ms,
210
225
  lease_expires_ms=excluded.lease_expires_ms,
226
+ presence_generation=agents.presence_generation + 1,
211
227
  status=excluded.status,
212
228
  metadata_json=excluded.metadata_json`),
213
229
  getAgent: db.prepare("SELECT * FROM agents WHERE agent_id = ?"),
@@ -215,24 +231,30 @@ export function createStore(dbPath, options = {}) {
215
231
  "UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?",
216
232
  ),
217
233
  heartbeat: db.prepare(
234
+ "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=? AND presence_generation=?",
235
+ ),
236
+ heartbeatWithoutGeneration: db.prepare(
218
237
  "UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?",
219
238
  ),
220
- setAgentStatus: db.prepare("UPDATE agents SET status=? WHERE agent_id=?"),
239
+ setAgentStatus: db.prepare(
240
+ "UPDATE agents SET status=? WHERE agent_id=? AND presence_generation=?",
241
+ ),
242
+ setAgentStatusWithoutGeneration: db.prepare(
243
+ "UPDATE agents SET status=? WHERE agent_id=?",
244
+ ),
221
245
  onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
222
246
  allAgents: db.prepare("SELECT * FROM agents"),
223
247
  agentsByTopic: db.prepare(
224
248
  "SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'",
225
249
  ),
226
250
  markStale: db.prepare(
227
- "UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?",
251
+ "UPDATE agents SET status='stale' WHERE agent_id=? AND presence_generation=? AND status='online' AND lease_expires_ms < ?",
228
252
  ),
229
253
  markOffline: db.prepare(
230
- "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
254
+ "UPDATE agents SET status='offline' WHERE agent_id=? AND presence_generation=? AND status='stale' AND lease_expires_ms < ? - 300000",
231
255
  ),
232
256
 
233
- getRole: db.prepare(
234
- "SELECT * FROM role_registry WHERE role_key_wire = ?",
235
- ),
257
+ getRole: db.prepare("SELECT * FROM role_registry WHERE role_key_wire = ?"),
236
258
  insertRoleReservation: db.prepare(`
237
259
  INSERT INTO role_registry (
238
260
  role_key_wire, project_id, role_kind, scope_id, state,
@@ -289,9 +311,7 @@ export function createStore(dbPath, options = {}) {
289
311
  AND activation_seq=@expected_activation_seq
290
312
  AND activation_id IS @expected_activation_id
291
313
  AND version=@expected_version`),
292
- listRoles: db.prepare(
293
- "SELECT * FROM role_registry ORDER BY role_key_wire",
294
- ),
314
+ listRoles: db.prepare("SELECT * FROM role_registry ORDER BY role_key_wire"),
295
315
  expiredHolderRoles: db.prepare(`
296
316
  SELECT * FROM role_registry
297
317
  WHERE holder_agent_id IS NOT NULL
@@ -419,12 +439,9 @@ export function createStore(dbPath, options = {}) {
419
439
  "UPDATE human_requests SET state=?, response_json=? WHERE request_id=?",
420
440
  ),
421
441
  pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
422
- expireHR: db.prepare(
423
- "UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?",
424
- ),
425
442
 
426
443
  insertDL: db.prepare(
427
- "INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)",
444
+ "INSERT INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?) ON CONFLICT(message_id) DO NOTHING",
428
445
  ),
429
446
  getDL: db.prepare(
430
447
  "SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?",
@@ -471,7 +488,13 @@ export function createStore(dbPath, options = {}) {
471
488
  WHERE job_id=@job_id`),
472
489
 
473
490
  findExpired: db.prepare(
474
- "SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?",
491
+ "SELECT * FROM messages WHERE status IN ('queued', 'delivered') AND expires_at_ms <= ?",
492
+ ),
493
+ claimExpired: db.prepare(
494
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status IN ('queued', 'delivered') AND expires_at_ms <= ?",
495
+ ),
496
+ claimDeadLetter: db.prepare(
497
+ "UPDATE messages SET status='dead_letter' WHERE id=? AND status != 'dead_letter'",
475
498
  ),
476
499
  urgentDepth: db.prepare(
477
500
  "SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7",
@@ -629,27 +652,28 @@ export function createStore(dbPath, options = {}) {
629
652
  previous_holder_agent_id:
630
653
  input.previous_holder_agent_id ??
631
654
  (current?.holder_agent_id !== input.holder_agent_id
632
- ? current?.holder_agent_id ?? null
633
- : current?.previous_holder_agent_id ?? null),
655
+ ? (current?.holder_agent_id ?? null)
656
+ : (current?.previous_holder_agent_id ?? null)),
634
657
  epoch: nextEpoch,
635
658
  activation_seq: nextActivationSeq,
636
659
  activation_id:
637
660
  input.activation_id ??
638
- (input.holder_agent_id ? uuidv7() : current?.activation_id ?? null),
661
+ (input.holder_agent_id ? uuidv7() : (current?.activation_id ?? null)),
639
662
  activation_deadline_ms:
640
663
  input.activation_deadline_ms ?? current?.activation_deadline_ms ?? null,
641
664
  holder_lease_expires_ms:
642
665
  input.holder_lease_expires_ms ??
643
666
  current?.holder_lease_expires_ms ??
644
667
  null,
645
- charter_version: input.charter_version ?? current?.charter_version ?? null,
668
+ charter_version:
669
+ input.charter_version ?? current?.charter_version ?? null,
646
670
  charter_acked_epoch:
647
671
  input.charter_acked_epoch ?? current?.charter_acked_epoch ?? null,
648
672
  retry_count: input.retry_count ?? current?.retry_count ?? 0,
649
673
  next_probe_ms: input.next_probe_ms ?? current?.next_probe_ms ?? null,
650
674
  blocked_reason:
651
675
  input.blocked_reason === undefined
652
- ? current?.blocked_reason ?? null
676
+ ? (current?.blocked_reason ?? null)
653
677
  : input.blocked_reason,
654
678
  last_transition_ms: input.last_transition_ms ?? Date.now(),
655
679
  last_reason: input.last_reason ?? "role_reserved",
@@ -701,20 +725,18 @@ export function createStore(dbPath, options = {}) {
701
725
  return {
702
726
  roles,
703
727
  candidates: roles.flatMap((role) =>
704
- S.listRoleCandidates
705
- .all(role.role_key_wire)
706
- .map(parseRoleCandidateRow),
728
+ S.listRoleCandidates.all(role.role_key_wire).map(parseRoleCandidateRow),
707
729
  ),
708
730
  pending_messages: roles.flatMap((role) =>
709
- S.pendingRoleMessages
710
- .all(role.role_key_wire, now)
711
- .map(parseMessageRow),
731
+ S.pendingRoleMessages.all(role.role_key_wire, now).map(parseMessageRow),
712
732
  ),
713
733
  };
714
734
  });
715
735
 
716
736
  const store = {
717
737
  db,
738
+ type: "sqlite",
739
+ fingerprint: storeFingerprint(dbPath),
718
740
  uuidv7,
719
741
 
720
742
  close() {
@@ -748,6 +770,7 @@ export function createStore(dbPath, options = {}) {
748
770
  return {
749
771
  agent_id,
750
772
  lease_id: leaseId,
773
+ presence_generation: persisted?.presence_generation ?? null,
751
774
  lease_expires_ms: leaseExpires,
752
775
  server_time_ms: now,
753
776
  effective: {
@@ -760,6 +783,7 @@ export function createStore(dbPath, options = {}) {
760
783
  lease_expires_ms: persisted?.lease_expires_ms ?? null,
761
784
  store_type: "sqlite",
762
785
  db_path: dbPath ?? null,
786
+ store_fingerprint: storeFingerprint(dbPath),
763
787
  },
764
788
  };
765
789
  },
@@ -768,9 +792,12 @@ export function createStore(dbPath, options = {}) {
768
792
  return parseAgentRow(S.getAgent.get(id));
769
793
  },
770
794
 
771
- refreshLease(agentId, ttlMs = 30000) {
795
+ refreshLease(agentId, ttlMs = 30000, presenceGeneration) {
772
796
  const now = Date.now();
773
- const write = S.heartbeat.run(now, now + ttlMs, agentId);
797
+ const write =
798
+ presenceGeneration == null
799
+ ? S.heartbeatWithoutGeneration.run(now, now + ttlMs, agentId)
800
+ : S.heartbeat.run(now, now + ttlMs, agentId, presenceGeneration);
774
801
  return {
775
802
  agent_id: agentId,
776
803
  lease_expires_ms: now + ttlMs,
@@ -779,6 +806,7 @@ export function createStore(dbPath, options = {}) {
779
806
  updated: write.changes > 0,
780
807
  store_type: "sqlite",
781
808
  db_path: dbPath ?? null,
809
+ store_fingerprint: storeFingerprint(dbPath),
782
810
  },
783
811
  };
784
812
  },
@@ -804,14 +832,40 @@ export function createStore(dbPath, options = {}) {
804
832
 
805
833
  sweepStaleAgents() {
806
834
  const now = Date.now();
835
+ const candidates = S.allAgents.all();
836
+ let stale = 0;
837
+ let offline = 0;
838
+ for (const candidate of candidates) {
839
+ if (candidate.status === "online" && candidate.lease_expires_ms < now) {
840
+ stale += S.markStale.run(
841
+ candidate.agent_id,
842
+ candidate.presence_generation,
843
+ now,
844
+ ).changes;
845
+ } else if (
846
+ candidate.status === "stale" &&
847
+ candidate.lease_expires_ms < now - 300000
848
+ ) {
849
+ offline += S.markOffline.run(
850
+ candidate.agent_id,
851
+ candidate.presence_generation,
852
+ now,
853
+ ).changes;
854
+ }
855
+ }
807
856
  return {
808
- stale: S.markStale.run(now).changes,
809
- offline: S.markOffline.run(now).changes,
857
+ stale,
858
+ offline,
810
859
  };
811
860
  },
812
861
 
813
- updateAgentStatus(agentId, status) {
814
- return S.setAgentStatus.run(status, agentId).changes > 0;
862
+ updateAgentStatus(agentId, status, presenceGeneration) {
863
+ return (
864
+ (presenceGeneration == null
865
+ ? S.setAgentStatusWithoutGeneration.run(status, agentId)
866
+ : S.setAgentStatus.run(status, agentId, presenceGeneration)
867
+ ).changes > 0
868
+ );
815
869
  },
816
870
 
817
871
  getRole(roleKeyWire) {
@@ -837,7 +891,8 @@ export function createStore(dbPath, options = {}) {
837
891
  Object.hasOwn(input, "expectedActivationId") ||
838
892
  Object.hasOwn(input, "activation_id");
839
893
  const expected = {
840
- version: input.expected_version ?? input.expectedVersion ?? input.version,
894
+ version:
895
+ input.expected_version ?? input.expectedVersion ?? input.version,
841
896
  epoch: input.expected_epoch ?? input.expectedEpoch ?? input.epoch,
842
897
  activation_seq:
843
898
  input.expected_activation_seq ??
@@ -979,15 +1034,15 @@ export function createStore(dbPath, options = {}) {
979
1034
  0,
980
1035
  excluded_until_ms:
981
1036
  input.excluded_until_ms === undefined
982
- ? current?.excluded_until_ms ?? null
1037
+ ? (current?.excluded_until_ms ?? null)
983
1038
  : input.excluded_until_ms,
984
1039
  last_probe_ms:
985
1040
  input.last_probe_ms === undefined
986
- ? current?.last_probe_ms ?? null
1041
+ ? (current?.last_probe_ms ?? null)
987
1042
  : input.last_probe_ms,
988
1043
  last_probe_code:
989
1044
  input.last_probe_code === undefined
990
- ? current?.last_probe_code ?? null
1045
+ ? (current?.last_probe_code ?? null)
991
1046
  : input.last_probe_code,
992
1047
  updated_at_ms: input.updated_at_ms ?? Date.now(),
993
1048
  });
@@ -1002,9 +1057,11 @@ export function createStore(dbPath, options = {}) {
1002
1057
  if (!current) return { ok: false, reason: "not_found" };
1003
1058
 
1004
1059
  let failures =
1005
- input.consecutive_probe_failures ??
1006
- current.consecutive_probe_failures;
1007
- if (input.probe_failed === true || input.increment_probe_failures === true) {
1060
+ input.consecutive_probe_failures ?? current.consecutive_probe_failures;
1061
+ if (
1062
+ input.probe_failed === true ||
1063
+ input.increment_probe_failures === true
1064
+ ) {
1008
1065
  failures = current.consecutive_probe_failures + 1;
1009
1066
  } else if (input.probe_succeeded === true) {
1010
1067
  failures = 0;
@@ -1186,16 +1243,14 @@ export function createStore(dbPath, options = {}) {
1186
1243
  return S.pendingHR.all().map(parseHumanRequestRow);
1187
1244
  },
1188
1245
 
1189
- expireHumanRequests() {
1190
- return S.expireHR.run(Date.now()).changes;
1191
- },
1192
-
1193
1246
  moveToDeadLetter(messageId, reason, lastError = null) {
1194
- db.transaction(() => {
1195
- S.setMsgStatus.run("dead_letter", messageId);
1196
- S.insertDL.run(messageId, reason, Date.now(), lastError);
1247
+ const failedAt = Date.now();
1248
+ return db.transaction(() => {
1249
+ const changed = S.claimDeadLetter.run(messageId).changes;
1250
+ if (!changed) return false;
1251
+ S.insertDL.run(messageId, reason, failedAt, lastError);
1252
+ return true;
1197
1253
  })();
1198
- return true;
1199
1254
  },
1200
1255
 
1201
1256
  getDeadLetters(limit = 50) {
@@ -1465,16 +1520,27 @@ export function createStore(dbPath, options = {}) {
1465
1520
  });
1466
1521
  },
1467
1522
 
1468
- sweepExpired() {
1469
- const now = Date.now();
1523
+ sweepExpired({ now_ms = Date.now() } = {}) {
1470
1524
  return db.transaction(() => {
1471
- const expired = S.findExpired.all(now);
1472
- for (const { id } of expired) {
1473
- S.setMsgStatus.run("dead_letter", id);
1474
- S.insertDL.run(id, "ttl_expired", now, null);
1525
+ const transitions = [];
1526
+ for (const row of S.findExpired.all(now_ms)) {
1527
+ if (!S.claimExpired.run(row.id, now_ms).changes) continue;
1528
+ options.beforeDeadLetterInsert?.({
1529
+ message_id: row.id,
1530
+ reason: "ttl_expired",
1531
+ failed_at_ms: now_ms,
1532
+ });
1533
+ const dlqInserted =
1534
+ S.insertDL.run(row.id, "ttl_expired", now_ms, null).changes === 1;
1535
+ transitions.push({
1536
+ message: parseMessageRow(row),
1537
+ previous_status: row.status,
1538
+ reason: "ttl_expired",
1539
+ failed_at_ms: now_ms,
1540
+ dlq_inserted: dlqInserted,
1541
+ });
1475
1542
  }
1476
- const humanRequests = S.expireHR.run(now).changes;
1477
- return { messages: expired.length, human_requests: humanRequests };
1543
+ return { now_ms, messages: transitions.length, transitions };
1478
1544
  })();
1479
1545
  },
1480
1546
 
@@ -21,10 +21,12 @@ export function buildAntigravityCommand(
21
21
  // and `"` reach the shell verbatim (P1: shell injection).
22
22
  const promptFile = `${resultFile}.prompt`;
23
23
  writeFileSync(promptFile, prompt);
24
+ // agy 1.1.27 부터 --print 는 값(프롬프트)이 필수이고 stdin 프롬프트는 거부된다.
25
+ // 파일 내용을 셸이 재해석하지 않는 형태(따옴표 안 명령 치환 / Get-Content 식)로 값에 넣는다.
24
26
  if (isWindows) {
25
- return `Get-Content -Raw '${promptFile}' | agy --print --dangerously-skip-permissions > '${resultFile}' 2>'${resultFile}.err'`;
27
+ return `agy --dangerously-skip-permissions --print (Get-Content -Raw '${promptFile}') > '${resultFile}' 2>'${resultFile}.err'`;
26
28
  }
27
- return `agy --print --dangerously-skip-permissions < '${promptFile}' > '${resultFile}' 2>'${resultFile}.err'`;
29
+ return `agy --dangerously-skip-permissions --print "$(cat '${promptFile}')" > '${resultFile}' 2>'${resultFile}.err'`;
28
30
  }
29
31
 
30
32
  const _require = createRequire(import.meta.url);
@@ -321,10 +321,12 @@ export async function probeClaudeDaemonCandidates({
321
321
  short,
322
322
  sessionId,
323
323
  timeoutMs = 6000,
324
+ tmpRoot = "/tmp",
324
325
  } = {}) {
325
326
  const candidates = await buildClaudeDaemonDiscoveryCandidates({
326
327
  configDir,
327
328
  env,
329
+ tmpRoot,
328
330
  });
329
331
  const callerProvenance = detectCallerProvenance(env);
330
332
  const targetRequested = Boolean(short || sessionId);
@@ -88,6 +88,8 @@ export async function startHeadlessTeam({
88
88
  timeoutSec: timeoutSec || 900,
89
89
  layout,
90
90
  autoAttach: !!autoAttach,
91
+ leadPane: process.env.TMUX_PANE,
92
+ leadTmux: process.env.TMUX,
91
93
  dashboard: !!dashboard,
92
94
  dashboardLayout,
93
95
  dashboardSize: dashboardSize ?? 0.5,
@@ -5,6 +5,7 @@ import {
5
5
  attachSession,
6
6
  configureTeammateKeybindings,
7
7
  createSession,
8
+ splitAttachToClient,
8
9
  } from "../../../session.mjs";
9
10
  import { BOLD, DIM, GREEN, RESET } from "../../../shared.mjs";
10
11
  import { ok, warn } from "../../render.mjs";
@@ -62,12 +63,23 @@ export async function startMuxTeam({
62
63
 
63
64
  ok("CLI 초기화 대기 (3초)...");
64
65
  await new Promise((resolve) => setTimeout(resolve, 3000));
65
- await orchestrate(sessionId, assignments, {
66
+ const promptInjectionFailures = await orchestrate(sessionId, assignments, {
66
67
  hubUrl,
67
68
  teammateMode,
68
69
  lead: { target: leadTarget, cli: lead, task },
70
+ onInjectionFailure(failure) {
71
+ warn(
72
+ `${failure.cli} ${failure.role} prompt 제출 실패 (${failure.target}): ${failure.message}`,
73
+ );
74
+ },
69
75
  });
70
- ok("리드/워커 프롬프트 주입 완료");
76
+ if (promptInjectionFailures.length === 0) {
77
+ ok("리드/워커 프롬프트 주입 완료");
78
+ } else {
79
+ warn(
80
+ `prompt 제출 실패 ${promptInjectionFailures.length}건 — 세션 상태에 기록함`,
81
+ );
82
+ }
71
83
 
72
84
  return {
73
85
  sessionName: sessionId,
@@ -78,6 +90,7 @@ export async function startMuxTeam({
78
90
  teammateMode,
79
91
  startedAt: Date.now(),
80
92
  hubUrl,
93
+ promptInjectionFailures,
81
94
  members,
82
95
  panes: Object.fromEntries(
83
96
  members.map((member) => [
@@ -113,7 +126,15 @@ export async function startMuxTeam({
113
126
  );
114
127
  console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
115
128
  if (process.stdout.isTTY && process.stdin.isTTY) attachSession(sessionId);
116
- else {
129
+ else if (
130
+ splitAttachToClient(sessionId, {
131
+ targetPane: process.env.TMUX_PANE,
132
+ tmuxEnv: process.env.TMUX,
133
+ })
134
+ ) {
135
+ ok("non-TTY 환경: 바깥 tmux 클라이언트 창을 분할해 attach함");
136
+ console.log(` ${DIM}분할 pane에서 나가기: Ctrl+B → D${RESET}\n`);
137
+ } else {
117
138
  warn("TTY 미지원 환경이라 자동 attach를 생략함");
118
139
  console.log(` ${DIM}수동 연결: tfx multi attach${RESET}\n`);
119
140
  }
@@ -5,16 +5,28 @@ import {
5
5
  sessionExists,
6
6
  } from "../../session.mjs";
7
7
 
8
- export function normalizeTeammateMode(mode = "auto") {
8
+ export function normalizeTeammateMode(mode = "auto", deps = {}) {
9
+ const platform = deps.platform || process.platform;
10
+ const env = deps.env || process.env;
11
+ const detectMux = deps.detectMultiplexer || detectMultiplexer;
9
12
  const raw = String(mode).toLowerCase();
10
13
  if (raw === "inline" || raw === "native") return "in-process";
11
14
  if (raw === "headless" || raw === "hl") return "headless";
12
- if (raw === "in-process" || raw === "tmux" || raw === "psmux" || raw === "wt")
13
- return raw;
14
- if (raw === "windows-terminal" || raw === "windows_terminal") return "wt";
15
+ if (raw === "in-process" || raw === "tmux") return raw;
16
+ if (raw === "psmux") return platform === "win32" ? "psmux" : "in-process";
17
+ if (
18
+ raw === "wt" ||
19
+ raw === "windows-terminal" ||
20
+ raw === "windows_terminal"
21
+ ) {
22
+ return platform === "win32" ? "wt" : "in-process";
23
+ }
15
24
  if (raw === "auto") {
16
- if (process.env.TMUX) return "tmux";
17
- const mux = detectMultiplexer();
25
+ if (env.TMUX) return "tmux";
26
+ const mux = detectMux();
27
+ if (platform !== "win32") {
28
+ return mux === "tmux" ? "tmux" : "in-process";
29
+ }
18
30
  if (mux === "psmux") return "psmux";
19
31
  if (mux === "tmux") return "tmux";
20
32
  return "in-process";
@@ -57,9 +69,11 @@ export function isTeamAlive(state) {
57
69
  return sessionExists(state.sessionName);
58
70
  }
59
71
 
60
- export function ensureTmuxOrExit() {
61
- const mux = detectMultiplexer();
62
- if (mux) return mux;
72
+ export function ensureTmuxOrExit(deps = {}) {
73
+ const platform = deps.platform || process.platform;
74
+ const detectMux = deps.detectMultiplexer || detectMultiplexer;
75
+ const mux = detectMux();
76
+ if (mux && (platform === "win32" || mux === "tmux")) return mux;
63
77
  const error = new Error("tmux 미발견");
64
78
  error.code = "TMUX_REQUIRED";
65
79
  throw error;
@@ -21,6 +21,11 @@ import { dirname, join } from "node:path";
21
21
  import { createRegistry } from "@triflux/core/mesh/mesh-registry.mjs";
22
22
  import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
23
23
  import { codexProfileConfigOverrides } from "../../scripts/lib/codex-profile-config.mjs";
24
+ import {
25
+ buildDisabledCliError,
26
+ normalizeCliName,
27
+ resolveCliPolicy,
28
+ } from "../../scripts/lib/machine-profile.mjs";
24
29
  import { broker } from "@triflux/core/hub/account-broker.mjs";
25
30
  import { runPreflight as runCodexPreflight } from "@triflux/core/hub/codex-preflight.mjs";
26
31
  import {
@@ -606,30 +611,53 @@ export function createConductor(opts = {}) {
606
611
  }
607
612
  }
608
613
 
609
- const spawnSpec = buildSpawnSpecForMode(MODES.HEADLESS, {
610
- cli: session.config.agent,
611
- prompt: session.config.prompt,
612
- profile: session.config.profile,
613
- role: session.config.role,
614
- model: session.config.model,
615
- env: session.config.env,
616
- mcpServers: session.config.mcpServers,
617
- excludeMcpServers,
618
- resolveCommand: opts.deps?.resolveCliExecutable,
619
- onWarning: (message, details = {}) => {
620
- eventLog.append("mcp_server_skipped", {
621
- session: session.id,
622
- message,
623
- ...details,
624
- });
625
- emitter.emit("warning", {
626
- type: "mcp_server_skipped",
627
- session: session.id,
628
- message,
629
- ...details,
630
- });
631
- },
632
- });
614
+ let spawnSpec;
615
+ try {
616
+ spawnSpec = buildSpawnSpecForMode(MODES.HEADLESS, {
617
+ cli: session.config.agent,
618
+ prompt: session.config.prompt,
619
+ profile: session.config.profile,
620
+ role: session.config.role,
621
+ model: session.config.model,
622
+ env: session.config.env,
623
+ mcpServers: session.config.mcpServers,
624
+ excludeMcpServers,
625
+ resolveCommand: opts.deps?.resolveCliExecutable,
626
+ onWarning: (message, details = {}) => {
627
+ eventLog.append("mcp_server_skipped", {
628
+ session: session.id,
629
+ message,
630
+ ...details,
631
+ });
632
+ emitter.emit("warning", {
633
+ type: "mcp_server_skipped",
634
+ session: session.id,
635
+ message,
636
+ ...details,
637
+ });
638
+ },
639
+ });
640
+ } catch (err) {
641
+ // respawnSession 은 `void` 로 호출되므로 여기서 던지면 처리되지 않은 거부가
642
+ // 된다. spec 생성 실패를 명시 실패로 접어 세션이 매달리지 않게 한다.
643
+ // 정책 차단과 그 밖의 spec 생성 실패는 원인이 다르므로 이벤트를 나눈다.
644
+ const message = err?.message || String(err);
645
+ const blockedByPolicy = err?.code === "TFX_CLI_DISABLED";
646
+ const eventName = blockedByPolicy
647
+ ? "session_spawn_blocked"
648
+ : "session_spawn_failed";
649
+ const reason = blockedByPolicy ? "spawn_blocked" : "spawn_failed";
650
+ const detail = {
651
+ session: session.id,
652
+ agent: session.config.agent,
653
+ code: err?.code || null,
654
+ error: message,
655
+ };
656
+ eventLog.append(eventName, detail);
657
+ emitter.emit("warning", { type: eventName, ...detail });
658
+ handleFailure(session, `${reason}:${message}`);
659
+ return;
660
+ }
633
661
 
634
662
  // #90 branch guard: shard spawn cwd가 main 브랜치면 즉시 abort.
635
663
  // swarm shard는 반드시 shard 전용 worktree 브랜치에서 실행되어야 한다.
@@ -1176,6 +1204,34 @@ export function createConductor(opts = {}) {
1176
1204
  }
1177
1205
  }
1178
1206
 
1207
+ // TFX_DISABLE_* SSOT 집행. 계정 임대와 worker sandbox 생성보다 앞에 둔다.
1208
+ // 뒤에 두면 정책으로 거부된 세션이 샌드박스 디렉터리와 auth.json 사본을
1209
+ // 남기고, 임대 반납이 회로 차단기 실패로 집계돼 멀쩡한 계정이 cooldown 에
1210
+ // 들어간다. 다른 CLI 로 자동 전환하지 않고 호출자에게 그대로 던진다.
1211
+ // claude 처럼 정책 대상이 아닌 agent 는 프로파일 파일을 읽지 않는다 (F4 와 동일).
1212
+ if (!config.remote && normalizeCliName(config.agent)) {
1213
+ const blockedCli = buildDisabledCliError(
1214
+ config.agent,
1215
+ resolveCliPolicy(process.env),
1216
+ );
1217
+ if (blockedCli) {
1218
+ eventLog.append("session_spawn_blocked", {
1219
+ session: config.id,
1220
+ agent: config.agent,
1221
+ code: blockedCli.code || null,
1222
+ error: blockedCli.message,
1223
+ });
1224
+ emitter.emit("warning", {
1225
+ type: "session_spawn_blocked",
1226
+ session: config.id,
1227
+ agent: config.agent,
1228
+ code: blockedCli.code || null,
1229
+ error: blockedCli.message,
1230
+ });
1231
+ throw blockedCli;
1232
+ }
1233
+ }
1234
+
1179
1235
  // broker lease (graceful — broker null if accounts.json absent)
1180
1236
  let lease = null;
1181
1237
  const activeBroker = getActiveBroker();