triflux 10.42.0 → 10.44.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/README.md +2 -0
  2. package/adapters/codex/skills/tfx-harness/SKILL.md +30 -0
  3. package/adapters/codex/skills/tfx-harness/skill.json +5 -0
  4. package/bin/tfx-live.mjs +3 -1
  5. package/bin/triflux.mjs +14 -0
  6. package/config/routing-policy.json +1 -1
  7. package/hooks/keyword-rules.json +139 -19
  8. package/hooks/pipeline-stop.mjs +1 -1
  9. package/hub/bridge.mjs +80 -1
  10. package/hub/cli-adapter-base.mjs +111 -21
  11. package/hub/codex-adapter.mjs +8 -1
  12. package/hub/dynamic-routing-engine.mjs +1 -1
  13. package/hub/gemini-adapter.mjs +3 -1
  14. package/hub/intent.mjs +24 -28
  15. package/hub/lib/cto-env.mjs +46 -0
  16. package/hub/lib/memory-store.mjs +25 -13
  17. package/hub/lib/timeout-defaults.mjs +45 -0
  18. package/hub/lib/worker-lifecycle.mjs +101 -0
  19. package/hub/middleware/request-logger.mjs +4 -1
  20. package/hub/pipe.mjs +9 -0
  21. package/hub/role-contract.mjs +314 -0
  22. package/hub/role-control-events.mjs +113 -0
  23. package/hub/router.mjs +383 -29
  24. package/hub/schema.sql +62 -2
  25. package/hub/server.mjs +130 -43
  26. package/hub/store.mjs +609 -19
  27. package/hub/team/claude-daemon-control.mjs +28 -15
  28. package/hub/team/cli/commands/start/parse-args.mjs +2 -2
  29. package/hub/team/conductor.mjs +23 -2
  30. package/hub/team/execution-mode.mjs +12 -2
  31. package/hub/team/headless.mjs +311 -24
  32. package/hub/team/intervention.mjs +530 -0
  33. package/hub/team/retry-state-machine.mjs +12 -3
  34. package/hub/team/swarm-hypervisor.mjs +21 -2
  35. package/hub/team/swarm-locks.mjs +17 -1
  36. package/hub/team/swarm-preflight.mjs +47 -0
  37. package/hub/team/synapse-http.mjs +149 -4
  38. package/hub/team/uds-orchestrator.mjs +62 -9
  39. package/hub/team/worktree-lifecycle.mjs +56 -59
  40. package/hub/tools.mjs +19 -3
  41. package/hub/workers/codex-app-server-worker.mjs +54 -5
  42. package/hub/workers/codex-mcp.mjs +21 -5
  43. package/hub/workers/delegator-mcp.mjs +3 -49
  44. package/hud/constants.mjs +21 -0
  45. package/hud/context-monitor.mjs +18 -21
  46. package/hud/hud-qos-status.mjs +1 -1
  47. package/hud/providers/codex-probe.mjs +216 -0
  48. package/hud/providers/codex.mjs +206 -29
  49. package/hud/renderers.mjs +6 -9
  50. package/package.json +2 -1
  51. package/scripts/__tests__/lint-skills.test.mjs +68 -0
  52. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
  53. package/scripts/__tests__/tfx-route-node-entry.test.mjs +42 -6
  54. package/scripts/headless-guard.mjs +70 -23
  55. package/scripts/keyword-detector.mjs +55 -8
  56. package/scripts/lib/agent-route-policy.mjs +360 -0
  57. package/scripts/lib/cli-agy.mjs +1 -0
  58. package/scripts/lib/cli-claude.mjs +1 -0
  59. package/scripts/lib/cli-codex.mjs +23 -160
  60. package/scripts/lib/codex-profile-config.mjs +31 -5
  61. package/scripts/lib/keyword-rules.mjs +12 -0
  62. package/scripts/lint-skills.mjs +65 -0
  63. package/scripts/pack.mjs +13 -0
  64. package/scripts/release/check-packages-mirror.mjs +5 -0
  65. package/scripts/setup.mjs +108 -12
  66. package/scripts/tfx-gate-activate.mjs +1 -1
  67. package/scripts/tfx-route-worker.mjs +5 -0
  68. package/scripts/tfx-route.mjs +14 -1
  69. package/scripts/tfx-route.sh +333 -118
  70. package/skills/tfx-analysis/SKILL.md +3 -1
  71. package/skills/tfx-harness/SKILL.md +19 -76
  72. package/skills/tfx-hub/SKILL.md +2 -2
  73. package/skills/tfx-interview/SKILL.md +3 -1
  74. package/skills/tfx-interview/skill.json +1 -13
  75. package/skills/tfx-plan/SKILL.md +3 -1
  76. package/skills/tfx-profile/SKILL.md +32 -22
  77. package/skills/tfx-prune/SKILL.md +7 -3
  78. package/skills/tfx-prune/skill.json +1 -8
  79. package/skills/tfx-qa/SKILL.md +6 -2
  80. package/skills/tfx-research/SKILL.md +3 -1
  81. package/skills/tfx-review/SKILL.md +5 -3
  82. package/skills/tfx-setup/SKILL.md +1 -1
  83. package/tui/codex-profile.mjs +14 -3
  84. package/tui/setup.mjs +6 -4
package/README.md CHANGED
@@ -264,6 +264,8 @@ state without replaying the entire conversation.
264
264
 
265
265
  ## Architecture
266
266
 
267
+ > 상세 구조·패키지 레이아웃·실행 경로는 [ARCHITECTURE.md](ARCHITECTURE.md), 문서 전체 지도는 [docs/README.md](docs/README.md) 참조.
268
+
267
269
  <p align="center">
268
270
  <img src="docs/assets/architecture.svg" alt="triflux architecture" width="680">
269
271
  </p>
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: tfx-harness
3
+ description: >
4
+ Use for meta-routing questions. Read the canonical routing SSOT and return
5
+ exactly one branch and immediate owner; do not execute the requested work.
6
+ ---
7
+
8
+ # tfx-harness — Codex adapter
9
+
10
+ Read `.claude/rules/tfx-routing.md` D0–D11 before routing. This adapter only
11
+ translates host syntax: use `$skill` for an available owner. Do not copy the
12
+ routing tree, keyword rules, or an owner matrix here.
13
+
14
+ Return:
15
+
16
+ ```text
17
+ [tfx-harness]
18
+ branch: D<n>
19
+ owner: <exactly one immediate owner>
20
+ availability: available | unavailable | unknown
21
+ fallback_notice: <optional>
22
+ status: recommendation-only | dispatching | blocked
23
+ ```
24
+
25
+ - Never choose more than one branch or immediate owner.
26
+ - With unknown availability, do not infer a fallback. With measured
27
+ unavailability, state `owner unavailable → tfx-X fallback` before fallback.
28
+ - If the SSOT cannot be read, return `blocked: routing SSOT unavailable`.
29
+ - For a “which skill/path?” question, remain recommendation-only.
30
+ - Do not select downstream implementation agents or models.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "tfx-harness",
3
+ "version": "1.0.0",
4
+ "host": "codex"
5
+ }
package/bin/tfx-live.mjs CHANGED
@@ -23,7 +23,9 @@ const DEFAULT_POLL_INTERVAL_MS = 1500;
23
23
  const DEFAULT_READY_TIMEOUT_MS = 30_000;
24
24
  const DEFAULT_ANSWER_TIMEOUT_MS = 60_000;
25
25
  const FALLBACK_QUIET_POLLS = 3;
26
- const CAPTURE_START = "-200";
26
+ // Capture the complete pane history so a long TUI response does not lose its
27
+ // leading lines. runTmux's MAX_BUFFER remains the memory/output ceiling.
28
+ const CAPTURE_START = "-";
27
29
  const MAX_BUFFER = 10 * 1024 * 1024;
28
30
  // execFile timeout for a bridge verb = attach timeout + this buffer, so the
29
31
  // helper's own timeoutMs fires first and returns {timedOut:true} rather than
package/bin/triflux.mjs CHANGED
@@ -94,6 +94,7 @@ import {
94
94
  SKILL_ALIASES,
95
95
  SYNC_MAP,
96
96
  syncAliasedSkillDir,
97
+ syncCodexHarnessAdapter,
97
98
  } from "../scripts/setup.mjs";
98
99
  import { cleanupTmpFiles } from "../scripts/tmp-cleanup.mjs";
99
100
 
@@ -2203,6 +2204,19 @@ function cmdSetup(options = {}) {
2203
2204
  }
2204
2205
  }
2205
2206
 
2207
+ const codexHarnessSync = syncCodexHarnessAdapter();
2208
+ if (!codexHarnessSync.ok) {
2209
+ fail(`Codex tfx-harness: ${codexHarnessSync.reason}`);
2210
+ return;
2211
+ }
2212
+ if (codexHarnessSync.action === "synced") {
2213
+ ok("Codex tfx-harness adapter: 동기화됨");
2214
+ } else if (codexHarnessSync.action === "skipped") {
2215
+ warn(
2216
+ `Codex tfx-harness adapter: 사용자 스킬 보존 (backup: ${codexHarnessSync.backupDir})`,
2217
+ );
2218
+ }
2219
+
2206
2220
  // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
2207
2221
  if (process.platform === "win32" && which("psmux")) {
2208
2222
  try {
@@ -109,7 +109,7 @@
109
109
  "fallback": {
110
110
  "default_mode": "codex-default",
111
111
  "default_cli": "codex",
112
- "default_model": "gpt-5.5",
112
+ "default_model": "gpt-5.6-terra",
113
113
  "triggers": [
114
114
  "hud_null",
115
115
  "hud_stale_gte_15min",
@@ -96,6 +96,102 @@
96
96
  "state": null,
97
97
  "mcp_route": null
98
98
  },
99
+ {
100
+ "id": "tfx-review",
101
+ "patterns": [
102
+ {
103
+ "source": "\\btfx[\\s-]?review\\b",
104
+ "flags": "i"
105
+ }
106
+ ],
107
+ "skill": "tfx-review",
108
+ "priority": 1,
109
+ "supersedes": ["tfx-unified"],
110
+ "exclusive": false,
111
+ "state": null,
112
+ "mcp_route": null,
113
+ "explicit": true
114
+ },
115
+ {
116
+ "id": "tfx-analysis",
117
+ "patterns": [
118
+ {
119
+ "source": "\\btfx[\\s-]?analysis\\b",
120
+ "flags": "i"
121
+ }
122
+ ],
123
+ "skill": "tfx-analysis",
124
+ "priority": 1,
125
+ "supersedes": ["tfx-unified"],
126
+ "exclusive": false,
127
+ "state": null,
128
+ "mcp_route": null,
129
+ "explicit": true
130
+ },
131
+ {
132
+ "id": "tfx-plan",
133
+ "patterns": [
134
+ {
135
+ "source": "\\btfx[\\s-]?plan\\b",
136
+ "flags": "i"
137
+ }
138
+ ],
139
+ "skill": "tfx-plan",
140
+ "priority": 1,
141
+ "supersedes": ["tfx-unified"],
142
+ "exclusive": false,
143
+ "state": null,
144
+ "mcp_route": null,
145
+ "explicit": true
146
+ },
147
+ {
148
+ "id": "tfx-qa",
149
+ "patterns": [
150
+ {
151
+ "source": "\\btfx[\\s-]?qa\\b",
152
+ "flags": "i"
153
+ }
154
+ ],
155
+ "skill": "tfx-qa",
156
+ "priority": 1,
157
+ "supersedes": ["tfx-unified"],
158
+ "exclusive": false,
159
+ "state": null,
160
+ "mcp_route": null,
161
+ "explicit": true
162
+ },
163
+ {
164
+ "id": "tfx-research",
165
+ "patterns": [
166
+ {
167
+ "source": "\\btfx[\\s-]?research\\b",
168
+ "flags": "i"
169
+ }
170
+ ],
171
+ "skill": "tfx-research",
172
+ "priority": 1,
173
+ "supersedes": ["tfx-unified"],
174
+ "exclusive": false,
175
+ "state": null,
176
+ "mcp_route": null,
177
+ "explicit": true
178
+ },
179
+ {
180
+ "id": "tfx-find",
181
+ "patterns": [
182
+ {
183
+ "source": "\\btfx[\\s-]?find\\b",
184
+ "flags": "i"
185
+ }
186
+ ],
187
+ "skill": "tfx-find",
188
+ "priority": 1,
189
+ "supersedes": ["tfx-unified"],
190
+ "exclusive": false,
191
+ "state": null,
192
+ "mcp_route": null,
193
+ "explicit": true
194
+ },
99
195
  {
100
196
  "id": "tfx-unified",
101
197
  "patterns": [
@@ -127,10 +223,6 @@
127
223
  "source": "(?:찾아봐|조사해|검색해)",
128
224
  "flags": "i"
129
225
  },
130
- {
131
- "source": "(?:정리해|슬롭|클린업)",
132
- "flags": "i"
133
- },
134
226
  {
135
227
  "source": "(?:병렬|동시에|parallel|concurrent)",
136
228
  "flags": "i"
@@ -155,6 +247,41 @@
155
247
  "state": null,
156
248
  "mcp_route": null
157
249
  },
250
+ {
251
+ "id": "host-ai-slop-cleaner",
252
+ "patterns": [
253
+ {
254
+ "source": "(?:AI\\s*슬롭|ai\\s*slop|anti[\\s-]?slop|deslop)",
255
+ "flags": "i"
256
+ },
257
+ {
258
+ "source": "(?:슬롭|클린업)",
259
+ "flags": "i"
260
+ }
261
+ ],
262
+ "skill": "ai-slop-cleaner",
263
+ "priority": 1,
264
+ "supersedes": [],
265
+ "exclusive": false,
266
+ "state": null,
267
+ "mcp_route": null
268
+ },
269
+ {
270
+ "id": "tfx-prune",
271
+ "patterns": [
272
+ {
273
+ "source": "\\btfx[\\s-]?prune\\b",
274
+ "flags": "i"
275
+ }
276
+ ],
277
+ "skill": "tfx-prune",
278
+ "priority": 1,
279
+ "supersedes": ["tfx-unified"],
280
+ "exclusive": false,
281
+ "state": null,
282
+ "mcp_route": null,
283
+ "explicit": true
284
+ },
158
285
  {
159
286
  "id": "tfx-codex",
160
287
  "patterns": [
@@ -586,7 +713,6 @@
586
713
  },
587
714
  {
588
715
  "id": "gstack-ship",
589
- "disabled": true,
590
716
  "patterns": [
591
717
  {
592
718
  "source": "(?:배포해|PR\\s*만들|릴리스\\s*해|머지하고\\s*배포)",
@@ -598,7 +724,7 @@
598
724
  }
599
725
  ],
600
726
  "skill": "ship",
601
- "priority": 5,
727
+ "priority": 1,
602
728
  "supersedes": [],
603
729
  "exclusive": false,
604
730
  "state": null,
@@ -627,12 +753,12 @@
627
753
  "id": "gstack-cso",
628
754
  "patterns": [
629
755
  {
630
- "source": "(?:보안\\s*(?:감사|점검|리뷰|스캔)|\\bcso\\b|\\bOWASP\\b|\\bSTRIDE\\b)",
756
+ "source": "(?:보안\\s*(?:감사|점검|리뷰|검토|스캔)|\\bcso\\b|\\bOWASP\\b|\\bSTRIDE\\b)",
631
757
  "flags": "i"
632
758
  }
633
759
  ],
634
760
  "skill": "cso",
635
- "priority": 5,
761
+ "priority": 1,
636
762
  "supersedes": [],
637
763
  "exclusive": false,
638
764
  "state": null,
@@ -651,7 +777,7 @@
651
777
  }
652
778
  ],
653
779
  "skill": "qa",
654
- "priority": 5,
780
+ "priority": 1,
655
781
  "supersedes": [],
656
782
  "exclusive": false,
657
783
  "state": null,
@@ -722,14 +848,6 @@
722
848
  "source": "/ship\\b",
723
849
  "flags": "i"
724
850
  },
725
- {
726
- "source": "\\brelease\\b",
727
- "flags": "i"
728
- },
729
- {
730
- "source": "\\bpublish\\b",
731
- "flags": "i"
732
- },
733
851
  {
734
852
  "source": "배포(?!자|사|장|처)",
735
853
  "flags": ""
@@ -744,11 +862,13 @@
744
862
  }
745
863
  ],
746
864
  "skill": "tfx-ship",
747
- "priority": 3,
865
+ "priority": 1,
748
866
  "supersedes": ["gstack-ship"],
749
867
  "exclusive": false,
750
868
  "state": null,
751
- "mcp_route": null
869
+ "mcp_route": null,
870
+ "repo_scope": ["triflux"],
871
+ "explicit": true
752
872
  }
753
873
  ]
754
874
  }
@@ -152,7 +152,7 @@ function contextPercentsFromObject(value) {
152
152
  value.total_tokens ??
153
153
  value.totalTokens;
154
154
  // Fall back to a 1M context window when the payload omits one — Opus 4.x
155
- // [1M] and Codex gpt-5.5 both run on a 1M window, and assuming 200K there
155
+ // [1M] and Codex GPT-5.6 family run on a 1M window, and assuming 200K there
156
156
  // false-positives at ~15% real usage. Override via
157
157
  // TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
158
158
  const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
package/hub/bridge.mjs CHANGED
@@ -99,6 +99,11 @@ const HUB_OPERATIONS = Object.freeze({
99
99
  action: "register",
100
100
  httpPath: "/bridge/register",
101
101
  },
102
+ heartbeat: {
103
+ transport: "command",
104
+ action: "heartbeat",
105
+ httpPath: "/bridge/heartbeat",
106
+ },
102
107
  result: {
103
108
  transport: "command",
104
109
  action: "result",
@@ -637,6 +642,14 @@ async function cmdRegister(args) {
637
642
  return emitJson(result || unavailableResult());
638
643
  }
639
644
 
645
+ async function cmdHeartbeat(args) {
646
+ const outcome = await requestHub(HUB_OPERATIONS.heartbeat, {
647
+ agent_id: args.agent,
648
+ ttl_ms: args["ttl-ms"] != null ? Number(args["ttl-ms"]) : undefined,
649
+ });
650
+ return emitJson(outcome?.result || unavailableResult());
651
+ }
652
+
640
653
  async function cmdResult(args) {
641
654
  let output = "";
642
655
  if (args.file && existsSync(args.file)) {
@@ -1559,6 +1572,68 @@ async function cmdRetryStatus(args) {
1559
1572
  return true;
1560
1573
  }
1561
1574
 
1575
+ // intervene-run은 hub transport를 거치지 않는다. heartbeat가 hub 장애 중에도
1576
+ // process/daemon/pane 사다리를 실행할 수 있도록 순수 로컬 모듈만 호출한다.
1577
+ async function cmdInterveneRun(args) {
1578
+ try {
1579
+ const payload = readBridgePayload(args);
1580
+ const intervention = await import("./team/intervention.mjs");
1581
+ let rolloutFile = payload.rolloutFile || null;
1582
+ if (!rolloutFile && payload.cli === "codex" && payload.pid) {
1583
+ rolloutFile = await intervention.resolveCodexRolloutFile({
1584
+ pid: numericOption(payload.pid, undefined),
1585
+ codexHome: payload.codexHome,
1586
+ });
1587
+ }
1588
+ const seconds = (value) => {
1589
+ const parsed = numericOption(value, undefined);
1590
+ return parsed > 0 ? parsed * 1_000 : undefined;
1591
+ };
1592
+ const readActivitySignature = intervention.createFileActivitySource({
1593
+ files: [payload.stdoutLog, payload.stderrLog, payload.resultFile].filter(
1594
+ Boolean,
1595
+ ),
1596
+ rolloutFile,
1597
+ });
1598
+ const ladder = intervention.createInterventionLadder({
1599
+ target: {
1600
+ channel: payload.channel,
1601
+ pid: numericOption(payload.pid, undefined),
1602
+ paneId: payload.paneId,
1603
+ interactive: payload.interactive === true,
1604
+ cli: payload.cli,
1605
+ sessionId: payload.sessionId,
1606
+ codexHome: payload.codexHome,
1607
+ rolloutFile,
1608
+ daemon: payload.daemon,
1609
+ },
1610
+ reinstructPrompt: payload.reinstructPrompt || undefined,
1611
+ readActivitySignature,
1612
+ config: {
1613
+ ...(seconds(payload.reinstructWaitSec)
1614
+ ? { reinstructWaitMs: seconds(payload.reinstructWaitSec) }
1615
+ : {}),
1616
+ ...(seconds(payload.resumeWaitSec)
1617
+ ? { resumeWaitMs: seconds(payload.resumeWaitSec) }
1618
+ : {}),
1619
+ ...(seconds(payload.sigtermGraceSec)
1620
+ ? { sigtermGraceMs: seconds(payload.sigtermGraceSec) }
1621
+ : {}),
1622
+ },
1623
+ log: (event) =>
1624
+ process.stderr.write(`[tfx-intervene] ${JSON.stringify(event)}\n`),
1625
+ });
1626
+ return emitJson(await ladder.intervene());
1627
+ } catch (error) {
1628
+ return emitJson({
1629
+ ok: false,
1630
+ outcome: "failed",
1631
+ step: null,
1632
+ error: error?.message || String(error),
1633
+ });
1634
+ }
1635
+ }
1636
+
1562
1637
  export async function main(argv = process.argv.slice(2)) {
1563
1638
  const cmd = argv[0];
1564
1639
  const args = parseArgs(argv.slice(1));
@@ -1566,6 +1641,8 @@ export async function main(argv = process.argv.slice(2)) {
1566
1641
  switch (cmd) {
1567
1642
  case "register":
1568
1643
  return await cmdRegister(args);
1644
+ case "heartbeat":
1645
+ return await cmdHeartbeat(args);
1569
1646
  case "result":
1570
1647
  return await cmdResult(args);
1571
1648
  case "control":
@@ -1630,9 +1707,11 @@ export async function main(argv = process.argv.slice(2)) {
1630
1707
  return await cmdRetryRun(args);
1631
1708
  case "retry-status":
1632
1709
  return await cmdRetryStatus(args);
1710
+ case "intervene-run":
1711
+ return await cmdInterveneRun(args);
1633
1712
  default:
1634
1713
  console.error(
1635
- "사용법: bridge.mjs <register|result|control|handoff|publish|takeover-role|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status> [--옵션]",
1714
+ "사용법: bridge.mjs <register|heartbeat|result|control|handoff|publish|takeover-role|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status|intervene-run> [--옵션]",
1636
1715
  );
1637
1716
  process.exit(1);
1638
1717
  }
@@ -6,7 +6,14 @@ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
 
7
7
  import { codexProfileConfigOverrides } from "../scripts/lib/codex-profile-config.mjs";
8
8
  import { writePromptToTmpFile } from "./lib/prompt-tmp.mjs";
9
+ import {
10
+ createActivityLifecycle,
11
+ isActivityLifecycleEnabled,
12
+ resolveHardCeilingMs,
13
+ resolveStallInterventionMs,
14
+ } from "./lib/worker-lifecycle.mjs";
9
15
  import { IS_WINDOWS, killProcess } from "./platform.mjs";
16
+ import { createInterventionLadder } from "./team/intervention.mjs";
10
17
 
11
18
  // ── Quota retry-after 파싱 ──────────────────────────────────────
12
19
 
@@ -160,7 +167,7 @@ export const CODEX_MCP_EXECUTION_EXIT_CODE = 1;
160
167
  *
161
168
  * @param {string} prompt
162
169
  * @param {string|null} resultFile — null이면 --output-last-message 생략
163
- * @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
170
+ * @param {{ profile?: string, codexHome?: string, disallowUltra?: boolean, enforceCanonicalProfile?: boolean, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
164
171
  * @returns {string} 실행할 셸 커맨드
165
172
  */
166
173
  export function buildExecCommand(prompt, resultFile = null, opts = {}) {
@@ -170,6 +177,9 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
170
177
  sandboxBypass = true,
171
178
  mcpServers,
172
179
  stdinPrompt,
180
+ codexHome,
181
+ disallowUltra,
182
+ enforceCanonicalProfile,
173
183
  } = opts;
174
184
 
175
185
  const parts = ["codex"];
@@ -178,7 +188,13 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
178
188
  // still contains an inline [profiles.X] table (and codex re-injects such
179
189
  // tables when it rewrites config.toml), so the `-c model=.. -c
180
190
  // model_reasoning_effort=..` form is immune and mutates no config.
181
- const profileOverrides = profile ? codexProfileConfigOverrides(profile) : [];
191
+ const profileOverrides = profile
192
+ ? codexProfileConfigOverrides(profile, {
193
+ codexHome,
194
+ disallowUltra,
195
+ enforceCanonicalProfile,
196
+ })
197
+ : [];
182
198
 
183
199
  if (FEATURES.execSubcommand) {
184
200
  parts.push("exec");
@@ -397,19 +413,36 @@ export async function terminateChild(pid, opts = {}) {
397
413
  *
398
414
  * @param {string} command — shell command to run
399
415
  * @param {string} workdir — cwd for the child process
400
- * @param {number} timeout — max duration in ms
416
+ * @param {number} timeout — legacy wall-clock duration (TFX_ACTIVITY_LIFECYCLE=0 only)
401
417
  * @param {object} [opts]
402
418
  * @param {string} [opts.resultFile] — file to read output from (if CLI writes there)
403
419
  * @param {function} [opts.inferStallMode] — (stdout, stderr) => string. Default: () => 'timeout'
404
420
  * @param {number} [opts.stallCheckIntervalMs] — stall check interval (default 10_000)
405
- * @param {number} [opts.stallThresholdMs] — stall threshold (default 30_000)
421
+ * @param {number} [opts.stallThresholdMs] — inactivity threshold
422
+ * @param {number} [opts.hardCeilingMs] — activity-independent maximum duration
423
+ * @param {(context: object) => Promise<boolean|string>|boolean|string} [opts.onStallIntervene]
424
+ * T5 intervention seam; true or "resolved" keeps the process alive
425
+ * @param {object} [opts.deps] — clock/process/timer overrides for deterministic tests
406
426
  * @returns {Promise<object>} createResult-shaped object
407
427
  */
408
428
  export async function runProcess(command, workdir, timeout, opts = {}) {
409
- const startedAt = Date.now();
429
+ const deps = opts.deps || {};
430
+ const now = deps.now || Date.now;
431
+ const spawnProcess = deps.spawn || spawn;
432
+ const scheduleTimeout = deps.setTimeout || setTimeout;
433
+ const cancelTimeout = deps.clearTimeout || clearTimeout;
434
+ const scheduleInterval = deps.setInterval || setInterval;
435
+ const cancelInterval = deps.clearInterval || clearInterval;
436
+ const terminateProcess = deps.terminateChild || terminateChild;
437
+ const startedAt = now();
410
438
  const inferStallMode = opts.inferStallMode || (() => "timeout");
411
439
  const stallCheckIntervalMs = opts.stallCheckIntervalMs ?? 10_000;
412
- const stallThresholdMs = opts.stallThresholdMs ?? 30_000;
440
+ const legacyLifecycle = !isActivityLifecycleEnabled();
441
+ const stallThresholdMs =
442
+ opts.stallThresholdMs ??
443
+ (legacyLifecycle ? 30_000 : resolveStallInterventionMs());
444
+ const hardCeilingMs = opts.hardCeilingMs ?? resolveHardCeilingMs();
445
+ const earlyClassifyMs = opts.earlyClassifyMs ?? 30_000;
413
446
  const resultFile = opts.resultFile || null;
414
447
 
415
448
  let stdout = "";
@@ -421,7 +454,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
421
454
  try {
422
455
  // PRD A1: opts.spawnEnv 가 있으면 그 env 로 spawn (lease 의 authFile/env 적용).
423
456
  // undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
424
- child = spawn(command, {
457
+ child = spawnProcess(command, {
425
458
  cwd: workdir,
426
459
  shell: true,
427
460
  windowsHide: true,
@@ -430,7 +463,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
430
463
  } catch (error) {
431
464
  return createResult(false, {
432
465
  stderr: String(error?.message || error),
433
- duration: Date.now() - startedAt,
466
+ duration: now() - startedAt,
434
467
  });
435
468
  }
436
469
 
@@ -446,9 +479,40 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
446
479
 
447
480
  let lastBytes = 0;
448
481
  let lastResultFileSignature = resultFileSignature();
449
- let lastChange = Date.now();
482
+ let lastChange = now();
483
+ const activitySignature = () =>
484
+ `${Buffer.byteLength(stdout) + Buffer.byteLength(stderr)}:${resultFileSignature()}`;
485
+ let ladder = null;
486
+ const lifecycle = createActivityLifecycle({
487
+ enabled: !legacyLifecycle,
488
+ interventionMs: stallThresholdMs,
489
+ hardCeilingMs,
490
+ now,
491
+ onIntervene: async (context) => {
492
+ if (typeof opts.onStallIntervene === "function") {
493
+ const result = await opts.onStallIntervene(context);
494
+ return result === true || result === "resolved";
495
+ }
496
+ ladder ??= createInterventionLadder({
497
+ target: {
498
+ pid: child.pid,
499
+ cli: opts.cli,
500
+ codexHome: opts.codexHome,
501
+ rolloutFile: opts.rolloutFile,
502
+ },
503
+ readActivitySignature: activitySignature,
504
+ deps:
505
+ typeof opts.resumeHandler === "function"
506
+ ? { resumeHandler: opts.resumeHandler }
507
+ : {},
508
+ });
509
+ const result = await ladder.intervene();
510
+ return result?.outcome === "reactivated" || result?.outcome === "resumed";
511
+ },
512
+ });
450
513
  const touch = () => {
451
- lastChange = Date.now();
514
+ lastChange = now();
515
+ lifecycle.observe(activitySignature());
452
516
  };
453
517
  child.stdout?.on("data", (chunk) => {
454
518
  stdout += String(chunk);
@@ -466,13 +530,15 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
466
530
  const stopFor = async (mode) => {
467
531
  if (failureMode) return;
468
532
  failureMode = mode;
469
- await terminateChild(child.pid);
533
+ await terminateProcess(child.pid);
470
534
  };
471
535
 
472
- const timeoutTimer = setTimeout(() => {
473
- void stopFor("timeout");
474
- }, timeout);
475
- const stallTimer = setInterval(() => {
536
+ const timeoutTimer = legacyLifecycle
537
+ ? scheduleTimeout(() => {
538
+ void stopFor("timeout");
539
+ }, timeout)
540
+ : null;
541
+ const stallTimer = scheduleInterval(() => {
476
542
  const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
477
543
  const currentResultFileSignature = resultFileSignature();
478
544
  if (
@@ -482,13 +548,37 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
482
548
  lastBytes = size;
483
549
  lastResultFileSignature = currentResultFileSignature;
484
550
  touch();
551
+ }
552
+ if (legacyLifecycle) {
553
+ if (now() - lastChange >= stallThresholdMs)
554
+ void stopFor(inferStallMode(stdout, stderr));
485
555
  return;
486
556
  }
487
- if (Date.now() - lastChange >= stallThresholdMs)
557
+ const quietMs = now() - lastChange;
558
+ const mode = inferStallMode(stdout, stderr);
559
+ if (
560
+ quietMs >= earlyClassifyMs &&
561
+ (mode === "rate_limited" || mode === "auth_stall")
562
+ ) {
488
563
  void stopFor(inferStallMode(stdout, stderr));
564
+ return;
565
+ }
566
+ void lifecycle
567
+ .check({
568
+ pid: child.pid,
569
+ command,
570
+ workdir,
571
+ mode,
572
+ stdoutTail: stdout.slice(-2000),
573
+ stderrTail: stderr.slice(-2000),
574
+ })
575
+ .then((reason) => {
576
+ if (!reason) return;
577
+ void stopFor(reason === "hard_ceiling" ? "timeout" : mode);
578
+ });
489
579
  }, stallCheckIntervalMs);
490
- timeoutTimer.unref?.();
491
- stallTimer.unref?.();
580
+ timeoutTimer?.unref?.();
581
+ stallTimer?.unref?.();
492
582
 
493
583
  await new Promise((resolve) =>
494
584
  child.on("close", (code) => {
@@ -496,8 +586,8 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
496
586
  resolve();
497
587
  }),
498
588
  );
499
- clearTimeout(timeoutTimer);
500
- clearInterval(stallTimer);
589
+ if (timeoutTimer) cancelTimeout(timeoutTimer);
590
+ cancelInterval(stallTimer);
501
591
 
502
592
  const fileOutput =
503
593
  resultFile && existsSync(resultFile)
@@ -509,7 +599,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
509
599
  output,
510
600
  stderr,
511
601
  exitCode,
512
- duration: Date.now() - startedAt,
602
+ duration: now() - startedAt,
513
603
  failureMode: ok ? null : failureMode || "crash",
514
604
  });
515
605
  }